diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 00000000..71cc5b37 --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,104 @@ +# +# Copyright 2024 Adobe. All rights reserved. +# This file is licensed to you under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. You may obtain a copy +# of the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under +# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +# OF ANY KIND, either express or implied. See the License for the specific language +# governing permissions and limitations under the License. +# + +version: 2.1 +orbs: + # Use the CircleCI Android orb version that supports OpenJDK17 required by AGP 8.2+ + android: circleci/android@2.4.0 + codecov: codecov/codecov@3.2.4 + +jobs: + build-and-unit-test: + executor: + name: android/android-docker + resource-class: large + tag: 2024.01.1 + + steps: + - checkout + + - run: + name: Check format + command: make checkformat + + - run: + name: Check style + command: make checkstyle + + - run: + name: Javadoc + command: make javadoc + + - store_artifacts: + path: code/notificationbuilder/build/docs/javadoc + + - run: + name: Assemble phone + command: make assemble-phone + + - run: + name: Build Jitpack Library + command: make notificationbuilder-publish-maven-local-jitpack + + - run: + name: Build Test app + command: make assemble-app + + - run: + name: Run Unit tests + command: make unit-test-coverage + + - store_test_results: + path: code/notificationbuilder/build/test-results/testPhoneDebugUnitTest + + - codecov/upload: + file: code/notificationbuilder/build/reports/coverage/test/phone/debug/report.xml + flags: unit-tests + + + functional-test: + executor: + name: android/android-machine + resource-class: large + tag: 2024.01.1 + + steps: + - checkout + + - android/create-avd: + avd-name: myavd + install: true + system-image: system-images;android-29;default;x86 + + - android/start-emulator: + avd-name: myavd + no-window: true + post-emulator-launch-assemble-command: "" + restore-gradle-cache-prefix: v1a + + - run: + name: Run Functional tests + command: | + make functional-test-coverage + + - codecov/upload: + file: code/core/build/reports/coverage/androidTest/phone/connected/debug/report.xml + flags: functional-tests + + - android/save-gradle-cache: + cache-prefix: v1a + +workflows: + build: + jobs: + - build-and-unit-test + # - functional-test diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 00000000..26ea8e5d --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,52 @@ +# Contributing + +Thanks for choosing to contribute! + +The following are a set of guidelines to follow when contributing to this project. + +## Code Of Conduct + +This project adheres to the Adobe [code of conduct](../CODE_OF_CONDUCT.md). By participating, +you are expected to uphold this code. Please report unacceptable behavior to +[Grp-opensourceoffice@adobe.com](mailto:Grp-opensourceoffice@adobe.com). + +## Have A Question? + +Start by filing an issue. The existing committers on this project work to reach +consensus around project direction and issue solutions within issue threads +(when appropriate). + +## Contributor License Agreement + +All third-party contributions to this project must be accompanied by a signed contributor +license agreement. This gives Adobe permission to redistribute your contributions +as part of the project. [Sign our CLA](http://opensource.adobe.com/cla.html). You +only need to submit an Adobe CLA one time, so if you have submitted one previously, +you are good to go! + +## Code Reviews + +All submissions should come in the form of pull requests and need to be reviewed +by project committers. Read [GitHub's pull request documentation](https://help.github.com/articles/about-pull-requests/) +for more information on sending pull requests. + +Lastly, please follow the [pull request template](PULL_REQUEST_TEMPLATE.md) when +submitting a pull request! + +## Style Guide + +Code cleanliness and consistency is important. Please review and follow our code +[Style Guide](../Documentation/Contributing/StyleGuide.md) when contributing. + +## From Contributor To Committer + +We love contributions from our community! If you'd like to go a step beyond contributor +and become a committer with full write access and a say in the project, you must +be invited to the project. The existing committers employ an internal nomination +process that must reach lazy consensus (silence is approval) before invitations +are issued. If you feel you are qualified and want to get more deeply involved, +feel free to reach out to existing committers to have a conversation about that. + +## Security Issues + +Security issues shouldn't be reported on this issue tracker. Instead, [file an issue to our security experts](https://helpx.adobe.com/security/alertus.html) diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 00000000..524718e3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,16 @@ + + + +### Expected Behaviour + +### Actual Behaviour + +### Reproduce Scenario (including but not limited to) + +#### Steps to Reproduce + +#### Platform and Version + +#### Sample Code that illustrates the problem + +#### Logs taken while reproducing problem diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..dd6a02ab --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,45 @@ + + +## Description + + + +## Related Issue + + + + + + +## Motivation and Context + + + +## How Has This Been Tested? + + + + + +## Screenshots (if appropriate): + +## Types of changes + + + +- [ ] Bug fix (non-breaking change which fixes an issue) +- [ ] New feature (non-breaking change which adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to change) + +## Checklist: + + + + +- [ ] I have signed the [Adobe Open Source CLA](http://opensource.adobe.com/cla.html). +- [ ] My code follows the code style of this project. +- [ ] My change requires a change to the documentation. +- [ ] I have updated the documentation accordingly. +- [ ] I have read the **CONTRIBUTING** document. +- [ ] I have added tests to cover my changes. +- [ ] All new and existing tests passed. diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml new file mode 100644 index 00000000..e053484e --- /dev/null +++ b/.github/release-drafter.yml @@ -0,0 +1,16 @@ +# +# Copyright 2024 Adobe. All rights reserved. +# This file is licensed to you under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. You may obtain a copy +# of the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under +# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +# OF ANY KIND, either express or implied. See the License for the specific language +# governing permissions and limitations under the License. +# + +template: | + ## What’s Changed + + $CHANGES \ No newline at end of file diff --git a/.github/workflows/maven-release.yml b/.github/workflows/maven-release.yml new file mode 100644 index 00000000..23978656 --- /dev/null +++ b/.github/workflows/maven-release.yml @@ -0,0 +1,82 @@ +# +# Copyright 2024 Adobe. All rights reserved. +# This file is licensed to you under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. You may obtain a copy +# of the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under +# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +# OF ANY KIND, either express or implied. See the License for the specific language +# governing permissions and limitations under the License. +# + +name: Publish Release +on: + workflow_dispatch: + inputs: + component: + type: choice + description: UI Component to release + options: + - notificationbuilder + + tag: + description: 'tag/version' + required: true + + action_tag: + description: 'Create tag? ("no" to skip)' + required: true + default: 'yes' +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Set up Java + uses: actions/setup-java@v3 + with: + distribution: 'zulu' + java-version: 17 + + - name: Cache Gradle packages + uses: actions/cache@v2 + with: + path: ~/.gradle/caches + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle') }} + restore-keys: ${{ runner.os }}-gradle + + - name: Verify version + run: | + set -eo pipefail + echo Release version: ${{ github.event.inputs.tag }} + (./scripts/version.sh -n ${{ github.event.inputs.component }} -v ${{ github.event.inputs.tag }}) + + - name: Create GH Release + id: create_release + uses: release-drafter/release-drafter@v5 + if: ${{ github.event.inputs.action_tag == 'yes' }} + with: + name: v${{ github.event.inputs.tag }}-${{ github.event.inputs.component }} + tag: v${{ github.event.inputs.tag }}-${{ github.event.inputs.component }} + version: v${{ github.event.inputs.tag }}-${{ github.event.inputs.component }} + publish: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Import GPG key + env: + GPG_SECRET_KEYS: ${{ secrets.GPG_SECRET_KEYS }} + GPG_OWNERTRUST: ${{ secrets.GPG_OWNERTRUST }} + run: | + echo $GPG_SECRET_KEYS | base64 --decode | gpg --import --no-tty --batch --yes + echo $GPG_OWNERTRUST | base64 --decode | gpg --import-ownertrust --no-tty --batch --yes + + - name: Publish to Maven Central Repository + run: make ${{ github.event.inputs.component }}-publish + env: + SONATYPE_USERNAME: ${{ secrets.SONATYPE_USERNAME }} + SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }} + GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} + GPG_KEY_ID: ${{ secrets.GPG_KEY_ID }} diff --git a/.github/workflows/maven-snapshot.yml b/.github/workflows/maven-snapshot.yml new file mode 100644 index 00000000..85b45f0b --- /dev/null +++ b/.github/workflows/maven-snapshot.yml @@ -0,0 +1,57 @@ +# +# Copyright 2024 Adobe. All rights reserved. +# This file is licensed to you under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. You may obtain a copy +# of the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under +# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +# OF ANY KIND, either express or implied. See the License for the specific language +# governing permissions and limitations under the License. +# + +name: Publish Snapshot +on: + workflow_dispatch: + inputs: + release_notificationbuilder: + required: false + type: boolean + default: false + description: Release Notification Builder + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Set up Java + uses: actions/setup-java@v3 + with: + distribution: 'zulu' + java-version: 17 + + - name: Cache Gradle packages + uses: actions/cache@v2 + with: + path: ~/.gradle/caches + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle') }} + restore-keys: ${{ runner.os }}-gradle + + - name: Import GPG key + env: + GPG_SECRET_KEYS: ${{ secrets.GPG_SECRET_KEYS }} + GPG_OWNERTRUST: ${{ secrets.GPG_OWNERTRUST }} + run: | + echo $GPG_SECRET_KEYS | base64 --decode | gpg --import --no-tty --batch --yes + echo $GPG_OWNERTRUST | base64 --decode | gpg --import-ownertrust --no-tty --batch --yes + + - name: Publish NotificationBuilder to Maven Snapshot Repository + if: ${{ inputs.release_notificationbuilder }} + run: make notificationbuilder-publish-snapshot + env: + SONATYPE_USERNAME: ${{ secrets.SONATYPE_USERNAME }} + SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }} + GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} + GPG_KEY_ID: ${{ secrets.GPG_KEY_ID }} diff --git a/.github/workflows/update-version.yml b/.github/workflows/update-version.yml new file mode 100644 index 00000000..999a7547 --- /dev/null +++ b/.github/workflows/update-version.yml @@ -0,0 +1,53 @@ +# +# Copyright 2024 Adobe. All rights reserved. +# This file is licensed to you under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. You may obtain a copy +# of the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under +# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +# OF ANY KIND, either express or implied. See the License for the specific language +# governing permissions and limitations under the License. +# + +name: Update Versions +on: + workflow_dispatch: + inputs: + notificationbuilder-version: + description: 'New version to use for the NotificationBuilder. Example: 3.0.0' + required: false + + core-dependency: + description: '[Optional] Update Core dependency in pom.xml. Example: 3.0.0' + required: false + +jobs: + update-version: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - if: ${{ github.event.inputs.notificationbuilder-version != '' }} + name: Update NotificationBuilder version + run: (sh ./scripts/version.sh -u -n NotificationBuilder -v ${{ github.event.inputs.notificationbuilder-version }} -d "Core ${{ github.event.inputs.core-dependency }}") + + - name: Generate Commit Message + shell: bash + run: | + COMMIT_MSG="" + if [ "${{ github.event.inputs.notificationbuilder-version }}" ]; then + COMMIT_MSG="[NotificationBuilder-${{ github.event.inputs.notificationbuilder-version }}]" + fi + echo $COMMIT_MSG + echo COMMIT_MSG=$COMMIT_MSG >> $GITHUB_ENV + + - name: Create Pull Request + uses: peter-evans/create-pull-request@v5 + with: + delete-branch: true + commit-message: Update versions ${{ env.COMMIT_MSG }} + title: Update versions ${{ env.COMMIT_MSG }} + body: Update versions ${{ env.COMMIT_MSG }} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..12752376 --- /dev/null +++ b/.gitignore @@ -0,0 +1,95 @@ +# Built application files +*.apk +*.aar +*.ap_ +*.aab + +# Files for the ART/Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +bin/ +gen/ +out/ +# Uncomment the following line in case you need and you don't have the release build type files in your app +# release/ + +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Proguard folder generated by Eclipse +proguard/ + +# Log Files +*.log + +# Android Studio Navigation editor temp files +.navigation/ + +# Android Studio captures folder +captures/ + +# IntelliJ +*.iml +.idea/workspace.xml +.idea/tasks.xml +.idea/gradle.xml +.idea/assetWizardSettings.xml +.idea/dictionaries +.idea/libraries +# Android Studio 3 in .gitignore file. +.idea/caches +.idea/modules.xml +# Comment next line if keeping position of elements in Navigation Editor is relevant for you +.idea/navEditor.xml +.idea + +# Keystore files +# Uncomment the following lines if you do not want to check your keystore files in. +#*.jks +#*.keystore + +# External native build folder generated in Android Studio 2.2 and later +.externalNativeBuild +.cxx/ + +# Google Services (e.g. APIs or Firebase) +# google-services.json + +# Freeline +freeline.py +freeline/ +freeline_project_description.json + +# fastlane +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots +fastlane/test_output +fastlane/readme.md + +# Version control +vcs.xml + +# lint +lint/intermediates/ +lint/generated/ +lint/outputs/ +lint/tmp/ +# lint/reports/ + +# Ignore Gradle project-specific cache directory +.gradle + +# Ignore Gradle build output directory +build +jacoco.exec + +.DS_Store diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..1ae2aa26 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,79 @@ +# Adobe Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our project and community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contribute to a positive environment for our project and community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience +* Focusing on what is best, not just for us as individuals but for the overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others’ private information, such as a physical or email address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for behaviors that they deem inappropriate, threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies when an individual is representing the project or its community both within project spaces and in public spaces. Examples of representing a project or community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by first contacting the project team. Oversight of Adobe projects is handled by the Adobe Open Source Office, which has final say in any violations and enforcement of this Code of Conduct and can be reached at Grp-opensourceoffice@adobe.com. All complaints will be reviewed and investigated promptly and fairly. + +The project team must respect the privacy and security of the reporter of any incident. + +Project maintainers who do not follow or enforce the Code of Conduct may face temporary or permanent repercussions as determined by other members of the project's leadership or the Adobe Open Source Office. + +## Enforcement Guidelines + +Project maintainers will follow these Community Impact Guidelines in determining the consequences for any action they deem to be in violation of this Code of Conduct: + +**1. Correction** + +Community Impact: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. + +Consequence: A private, written warning from project maintainers describing the violation and why the behavior was unacceptable. A public apology may be requested from the violator before any further involvement in the project by violator. + +**2. Warning** + +Community Impact: A relatively minor violation through a single incident or series of actions. + +Consequence: A written warning from project maintainers that includes stated consequences for continued unacceptable behavior. Violator must refrain from interacting with the people involved for a specified period of time as determined by the project maintainers, including, but not limited to, unsolicited interaction with those enforcing the Code of Conduct through channels such as community spaces and social media. Continued violations may lead to a temporary or permanent ban. + +**3. Temporary Ban** + +Community Impact: A more serious violation of community standards, including sustained unacceptable behavior. + +Consequence: A temporary ban from any interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Failure to comply with the temporary ban may lead to a permanent ban. + +**4. Permanent Ban** + +Community Impact: Demonstrating a consistent pattern of violation of community standards or an egregious violation of community standards, including, but not limited to, sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. + +Consequence: A permanent ban from any interaction with the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, +available at [https://contributor-covenant.org/version/2/1][version] + +[homepage]: https://contributor-covenant.org +[version]: https://contributor-covenant.org/version/2/1 \ No newline at end of file diff --git a/COPYRIGHT b/COPYRIGHT new file mode 100644 index 00000000..88093633 --- /dev/null +++ b/COPYRIGHT @@ -0,0 +1,5 @@ +© Copyright 2015-2024 Adobe. All rights reserved. + +Adobe holds the copyright for all the files found in this repository. + +See the LICENSE file for licensing information. diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..5ec202a6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, +and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by +the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all +other entities that control, are controlled by, or are under common +control with that entity. For the purposes of this definition, +"control" means (i) the power, direct or indirect, to cause the +direction or management of such entity, whether by contract or +otherwise, or (ii) ownership of fifty percent (50%) or more of the +outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity +exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, +including but not limited to software source code, documentation +source, and configuration files. + +"Object" form shall mean any form resulting from mechanical +transformation or translation of a Source form, including but +not limited to compiled object code, generated documentation, +and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or +Object form, made available under the License, as indicated by a +copyright notice that is included in or attached to the work +(an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object +form, that is based on (or derived from) the Work and for which the +editorial revisions, annotations, elaborations, or other modifications +represent, as a whole, an original work of authorship. For the purposes +of this License, Derivative Works shall not include works that remain +separable from, or merely link (or bind by name) to the interfaces of, +the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including +the original version of the Work and any modifications or additions +to that Work or Derivative Works thereof, that is intentionally +submitted to Licensor for inclusion in the Work by the copyright owner +or by an individual or Legal Entity authorized to submit on behalf of +the copyright owner. For the purposes of this definition, "submitted" +means any form of electronic, verbal, or written communication sent +to the Licensor or its representatives, including but not limited to +communication on electronic mailing lists, source code control systems, +and issue tracking systems that are managed by, or on behalf of, the +Licensor for the purpose of discussing and improving the Work, but +excluding communication that is conspicuously marked or otherwise +designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity +on behalf of whom a Contribution has been received by Licensor and +subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of +this License, each Contributor hereby grants to You a perpetual, +worldwide, non-exclusive, no-charge, royalty-free, irrevocable +copyright license to reproduce, prepare Derivative Works of, +publicly display, publicly perform, sublicense, and distribute the +Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of +this License, each Contributor hereby grants to You a perpetual, +worldwide, non-exclusive, no-charge, royalty-free, irrevocable +(except as stated in this section) patent license to make, have made, +use, offer to sell, sell, import, and otherwise transfer the Work, +where such license applies only to those patent claims licensable +by such Contributor that are necessarily infringed by their +Contribution(s) alone or by combination of their Contribution(s) +with the Work to which such Contribution(s) was submitted. If You +institute patent litigation against any entity (including a +cross-claim or counterclaim in a lawsuit) alleging that the Work +or a Contribution incorporated within the Work constitutes direct +or contributory patent infringement, then any patent licenses +granted to You under this License for that Work shall terminate +as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the +Work or Derivative Works thereof in any medium, with or without +modifications, and in Source or Object form, provided that You +meet the following conditions: + +(a) You must give any other recipients of the Work or +Derivative Works a copy of this License; and + +(b) You must cause any modified files to carry prominent notices +stating that You changed the files; and + +(c) You must retain, in the Source form of any Derivative Works +that You distribute, all copyright, patent, trademark, and +attribution notices from the Source form of the Work, +excluding those notices that do not pertain to any part of +the Derivative Works; and + +(d) If the Work includes a "NOTICE" text file as part of its +distribution, then any Derivative Works that You distribute must +include a readable copy of the attribution notices contained +within such NOTICE file, excluding those notices that do not +pertain to any part of the Derivative Works, in at least one +of the following places: within a NOTICE text file distributed +as part of the Derivative Works; within the Source form or +documentation, if provided along with the Derivative Works; or, +within a display generated by the Derivative Works, if and +wherever such third-party notices normally appear. The contents +of the NOTICE file are for informational purposes only and +do not modify the License. You may add Your own attribution +notices within Derivative Works that You distribute, alongside +or as an addendum to the NOTICE text from the Work, provided +that such additional attribution notices cannot be construed +as modifying the License. + +You may add Your own copyright statement to Your modifications and +may provide additional or different license terms and conditions +for use, reproduction, or distribution of Your modifications, or +for any such Derivative Works as a whole, provided Your use, +reproduction, and distribution of the Work otherwise complies with +the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, +any Contribution intentionally submitted for inclusion in the Work +by You to the Licensor shall be under the terms and conditions of +this License, without any additional terms or conditions. +Notwithstanding the above, nothing herein shall supersede or modify +the terms of any separate license agreement you may have executed +with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade +names, trademarks, service marks, or product names of the Licensor, +except as required for reasonable and customary use in describing the +origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or +agreed to in writing, Licensor provides the Work (and each +Contributor provides its Contributions) on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +implied, including, without limitation, any warranties or conditions +of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A +PARTICULAR PURPOSE. You are solely responsible for determining the +appropriateness of using or redistributing the Work and assume any +risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, +whether in tort (including negligence), contract, or otherwise, +unless required by applicable law (such as deliberate and grossly +negligent acts) or agreed to in writing, shall any Contributor be +liable to You for damages, including any direct, indirect, special, +incidental, or consequential damages of any character arising as a +result of this License or out of the use or inability to use the +Work (including but not limited to damages for loss of goodwill, +work stoppage, computer failure or malfunction, or any and all +other commercial damages or losses), even if such Contributor +has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing +the Work or Derivative Works thereof, You may choose to offer, +and charge a fee for, acceptance of support, warranty, indemnity, +or other liability obligations and/or rights consistent with this +License. However, in accepting such obligations, You may act only +on Your own behalf and on Your sole responsibility, not on behalf +of any other Contributor, and only if You agree to indemnify, +defend, and hold each Contributor harmless for any liability +incurred by, or claims asserted against, such Contributor by reason +of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + +To apply the Apache License to your work, attach the following +boilerplate notice, with the fields enclosed by brackets "[]" +replaced with your own identifying information. (Don't include +the brackets!) The text should be enclosed in the appropriate +comment syntax for the file format. We also recommend that a +file or class name and description of purpose be included on the +same "printed page" as the copyright notice for easier +identification within third-party archives. + +Copyright 2019 Adobe + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..e82a5b52 --- /dev/null +++ b/Makefile @@ -0,0 +1,45 @@ +checkstyle: + (./code/gradlew -p code/notificationbuilder checkstyle) + +checkformat: + (./code/gradlew -p code/notificationbuilder spotlessCheck) + +format: + (./code/gradlew -p code/notificationbuilder spotlessApply) + +format-license: + (./code/gradlew -p code licenseFormat) + +javadoc: + (./code/gradlew -p code/notificationbuilder javadocJar) + +unit-test: + (./code/gradlew -p code/notificationbuilder testPhoneDebugUnitTest) + +unit-test-coverage: + (./code/gradlew -p code/notificationbuilder createPhoneDebugUnitTestCoverageReport) + +functional-test: + (./code/gradlew -p code/notificationbuilder uninstallPhoneDebugAndroidTest) + (./code/gradlew -p code/notificationbuilder connectedPhoneDebugAndroidTest) + +functional-test-coverage: + (./code/gradlew -p code/notificationbuilder createPhoneDebugAndroidTestCoverageReport) + +assemble-phone: + (./code/gradlew -p code/notificationbuilder assemblePhone) + +assemble-phone-release: + (./code/gradlew -p code/notificationbuilder assemblePhoneRelease) + +assemble-app: + (./code/gradlew -p code/testapp assemble) + +notificationbuilder-publish-maven-local-jitpack: assemble-phone-release + (./code/gradlew -p code/notificationbuilder publishReleasePublicationToMavenLocal -Pjitpack -x signReleasePublication) + +notificationbuilder-publish-snapshot: assemble-phone-release + (./code/gradlew -p code/notificationbuilder publishReleasePublicationToSonatypeRepository) + +notificationbuilder-publish: assemble-phone-release + (./code/gradlew -p code/notificationbuilder publishReleasePublicationToSonatypeRepository -Prelease) diff --git a/code/build.gradle.kts b/code/build.gradle.kts new file mode 100644 index 00000000..fec4663b --- /dev/null +++ b/code/build.gradle.kts @@ -0,0 +1,24 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +buildscript { + repositories { + gradlePluginPortal() + google() + mavenCentral() + maven { url = uri("https://jitpack.io") } + mavenLocal() + } + dependencies { + classpath("com.github.adobe:aepsdk-commons:gp-3.0.0") + } +} diff --git a/code/gradle.properties b/code/gradle.properties new file mode 100644 index 00000000..f87e7936 --- /dev/null +++ b/code/gradle.properties @@ -0,0 +1,29 @@ +# +# Copyright 2024 Adobe. All rights reserved. +# This file is licensed to you under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. You may obtain a copy +# of the License at http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under +# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +# OF ANY KIND, either express or implied. See the License for the specific language +# governing permissions and limitations under the License. +# + +org.gradle.jvmargs=-Xmx2048m +android.injected.testOnly = false +org.gradle.configureondemand=false +android.useAndroidX=true + +#Maven artifacts +#Notification Builder Module +notificationbuilderModuleName=notificationbuilder +notificationbuilderVersion=3.0.0 +notificationbuilderMavenRepoName=AdobeMobileNotificationBuilderSdk +notificationbuilderMavenRepoDescription=Android Notification Builder library for Adobe Mobile Marketing + +mavenCoreVersion=3.0.0 + + +android.nonTransitiveRClass=false +android.nonFinalResIds=false diff --git a/code/gradle/wrapper/gradle-wrapper.jar b/code/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..13372aef Binary files /dev/null and b/code/gradle/wrapper/gradle-wrapper.jar differ diff --git a/code/gradle/wrapper/gradle-wrapper.properties b/code/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..bf8503ad --- /dev/null +++ b/code/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Sun Jul 10 17:49:57 MDT 2022 +distributionBase=GRADLE_USER_HOME +distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip +distributionPath=wrapper/dists +zipStorePath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME diff --git a/code/gradlew b/code/gradlew new file mode 100755 index 00000000..9d82f789 --- /dev/null +++ b/code/gradlew @@ -0,0 +1,160 @@ +#!/usr/bin/env bash + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn ( ) { + echo "$*" +} + +die ( ) { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; +esac + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules +function splitJvmOpts() { + JVM_OPTS=("$@") +} +eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS +JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" + +exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" diff --git a/code/gradlew.bat b/code/gradlew.bat new file mode 100644 index 00000000..aec99730 --- /dev/null +++ b/code/gradlew.bat @@ -0,0 +1,90 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windowz variants + +if not "%OS%" == "Windows_NT" goto win9xME_args +if "%@eval[2+2]" == "4" goto 4NT_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* +goto execute + +:4NT_args +@rem Get arguments from the 4NT Shell from JP Software +set CMD_LINE_ARGS=%$ + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/code/notificationbuilder/build.gradle.kts b/code/notificationbuilder/build.gradle.kts new file mode 100644 index 00000000..60943c91 --- /dev/null +++ b/code/notificationbuilder/build.gradle.kts @@ -0,0 +1,43 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +plugins { + id("aep-library") +} + +val mavenCoreVersion: String by project +val notificationbuilderModuleName: String by project +val notificationbuilderVersion: String by project +val notificationbuilderMavenRepoName: String by project +val notificationbuilderMavenRepoDescription: String by project + + +aepLibrary { + namespace = "com.adobe.marketing.mobile.notificationbuilder" + moduleName = notificationbuilderModuleName + moduleVersion = notificationbuilderVersion + enableSpotless = true + enableCheckStyle = true + enableDokkaDoc = true + + publishing { + mavenRepoName = notificationbuilderMavenRepoName + mavenRepoDescription = notificationbuilderMavenRepoDescription + gitRepoName = "aepsdk-ui-android" + addCoreDependency(mavenCoreVersion) + } +} + +dependencies { + implementation("com.adobe.marketing.mobile:core:$mavenCoreVersion") + testImplementation("org.robolectric:robolectric:4.7") + testImplementation("io.mockk:mockk:1.13.11") +} diff --git a/code/notificationbuilder/src/main/AndroidManifest.xml b/code/notificationbuilder/src/main/AndroidManifest.xml new file mode 100755 index 00000000..f33b4529 --- /dev/null +++ b/code/notificationbuilder/src/main/AndroidManifest.xml @@ -0,0 +1,17 @@ + + + + + diff --git a/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/NotificationBuilder.kt b/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/NotificationBuilder.kt new file mode 100644 index 00000000..ba64f9a5 --- /dev/null +++ b/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/NotificationBuilder.kt @@ -0,0 +1,234 @@ +/* + Copyright 2024 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. +*/ + +package com.adobe.marketing.mobile.notificationbuilder + +import android.app.Activity +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import androidx.core.app.NotificationCompat +import com.adobe.marketing.mobile.notificationbuilder.NotificationBuilder.constructNotificationBuilder +import com.adobe.marketing.mobile.notificationbuilder.PushTemplateConstants.LOG_TAG +import com.adobe.marketing.mobile.notificationbuilder.internal.PushTemplateType +import com.adobe.marketing.mobile.notificationbuilder.internal.builders.AutoCarouselNotificationBuilder +import com.adobe.marketing.mobile.notificationbuilder.internal.builders.BasicNotificationBuilder +import com.adobe.marketing.mobile.notificationbuilder.internal.builders.InputBoxNotificationBuilder +import com.adobe.marketing.mobile.notificationbuilder.internal.builders.LegacyNotificationBuilder +import com.adobe.marketing.mobile.notificationbuilder.internal.builders.ManualCarouselNotificationBuilder +import com.adobe.marketing.mobile.notificationbuilder.internal.builders.MultiIconNotificationBuilder +import com.adobe.marketing.mobile.notificationbuilder.internal.builders.ProductCatalogNotificationBuilder +import com.adobe.marketing.mobile.notificationbuilder.internal.builders.ProductRatingNotificationBuilder +import com.adobe.marketing.mobile.notificationbuilder.internal.builders.TimerNotificationBuilder +import com.adobe.marketing.mobile.notificationbuilder.internal.builders.ZeroBezelNotificationBuilder +import com.adobe.marketing.mobile.notificationbuilder.internal.templates.AEPPushTemplate +import com.adobe.marketing.mobile.notificationbuilder.internal.templates.AutoCarouselPushTemplate +import com.adobe.marketing.mobile.notificationbuilder.internal.templates.BasicPushTemplate +import com.adobe.marketing.mobile.notificationbuilder.internal.templates.CarouselPushTemplate +import com.adobe.marketing.mobile.notificationbuilder.internal.templates.InputBoxPushTemplate +import com.adobe.marketing.mobile.notificationbuilder.internal.templates.ManualCarouselPushTemplate +import com.adobe.marketing.mobile.notificationbuilder.internal.templates.MultiIconPushTemplate +import com.adobe.marketing.mobile.notificationbuilder.internal.templates.ProductCatalogPushTemplate +import com.adobe.marketing.mobile.notificationbuilder.internal.templates.ProductRatingPushTemplate +import com.adobe.marketing.mobile.notificationbuilder.internal.templates.TimerPushTemplate +import com.adobe.marketing.mobile.notificationbuilder.internal.templates.ZeroBezelPushTemplate +import com.adobe.marketing.mobile.notificationbuilder.internal.util.IntentData +import com.adobe.marketing.mobile.notificationbuilder.internal.util.MapData +import com.adobe.marketing.mobile.notificationbuilder.internal.util.NotificationData +import com.adobe.marketing.mobile.services.Log +import com.adobe.marketing.mobile.services.ServiceProvider + +/** + * Public facing object to construct a [NotificationCompat.Builder] object for the specified [PushTemplateType]. + * The [constructNotificationBuilder] methods will build the appropriate notification based on the provided + * [AEPPushTemplate] or [Intent]. + */ +object NotificationBuilder { + private const val SELF_TAG = "NotificationBuilder" + private const val VERSION = "3.0.0" + + @JvmStatic + fun version(): String { + return VERSION + } + + /** + * Constructs a [NotificationCompat.Builder] object from the provided [messageData] + * + * @param messageData [Map] containing the data needed for the notification construction + * @param trackerActivityClass [Class] of the [Activity] to be launched when the notification is clicked + * @param broadcastReceiverClass [Class] of the [BroadcastReceiver] to be used for handling notification actions + * @return [NotificationCompat.Builder] object + * @throws [NotificationConstructionFailedException] if the notification construction fails due to missing data + * @throws [IllegalArgumentException] if the provided message data has invalid data + */ + @Throws(NotificationConstructionFailedException::class, IllegalArgumentException::class) + @JvmStatic + fun constructNotificationBuilder( + messageData: Map, + trackerActivityClass: Class?, + broadcastReceiverClass: Class? + ): NotificationCompat.Builder { + val context = ServiceProvider.getInstance().appContextService.applicationContext + ?: throw NotificationConstructionFailedException("Application context is null, cannot build a notification.") + if (messageData.isEmpty()) { + throw NotificationConstructionFailedException("Message data is empty, cannot build a notification.") + } + val notificationData = MapData(messageData) + return createNotificationBuilder(context, notificationData, trackerActivityClass, broadcastReceiverClass) + } + + /** + * Constructs a [NotificationCompat.Builder] object from the provided [intent] + * + * @param intent [Intent] containing the data needed for the notification construction + * @param trackerActivityClass [Class] of the [Activity] to be launched when the notification is clicked + * @param broadcastReceiverClass [Class] of the [BroadcastReceiver] to be used for handling notification actions + * @return [NotificationCompat.Builder] object + * @throws [NotificationConstructionFailedException] if the notification construction fails due to missing data + * @throws [IllegalArgumentException] if the provided message data has invalid data + */ + @Throws(NotificationConstructionFailedException::class, IllegalArgumentException::class) + @JvmStatic + fun constructNotificationBuilder( + intent: Intent, + trackerActivityClass: Class?, + broadcastReceiverClass: Class? + ): NotificationCompat.Builder { + val context = ServiceProvider.getInstance().appContextService.applicationContext + ?: throw NotificationConstructionFailedException("Application context is null, cannot build a notification.") + val extras = intent.extras ?: throw NotificationConstructionFailedException("Intent extras are null, cannot re-build the notification.") + val intentData = IntentData(extras, intent.action) + return createNotificationBuilder(context, intentData, trackerActivityClass, broadcastReceiverClass) + } + + private fun createNotificationBuilder( + context: Context, + notificationData: NotificationData, + trackerActivityClass: Class?, + broadcastReceiverClass: Class? + ): NotificationCompat.Builder { + + val pushTemplateType = + PushTemplateType.fromString(notificationData.getString(PushTemplateConstants.PushPayloadKeys.TEMPLATE_TYPE)) + + when (pushTemplateType) { + PushTemplateType.BASIC -> { + val basicPushTemplate = BasicPushTemplate(notificationData) + return BasicNotificationBuilder.construct( + context, + basicPushTemplate, + trackerActivityClass, + broadcastReceiverClass + ) + } + + PushTemplateType.CAROUSEL -> { + val carouselPushTemplate = + CarouselPushTemplate(notificationData) + + when (carouselPushTemplate) { + is AutoCarouselPushTemplate -> { + return AutoCarouselNotificationBuilder.construct( + context, + carouselPushTemplate, + trackerActivityClass, + broadcastReceiverClass + ) + } + + is ManualCarouselPushTemplate -> { + return ManualCarouselNotificationBuilder.construct( + context, + carouselPushTemplate, + trackerActivityClass, + broadcastReceiverClass + ) + } + + else -> { + Log.warning( + LOG_TAG, + SELF_TAG, + "Unknown carousel push template type, creating a legacy style notification." + ) + return LegacyNotificationBuilder.construct( + context, + BasicPushTemplate(notificationData), + trackerActivityClass + ) + } + } + } + + PushTemplateType.ZERO_BEZEL -> { + val zeroBezelPushTemplate = ZeroBezelPushTemplate(notificationData) + return ZeroBezelNotificationBuilder.construct( + context, + zeroBezelPushTemplate, + trackerActivityClass + ) + } + + PushTemplateType.INPUT_BOX -> { + return InputBoxNotificationBuilder.construct( + context, + InputBoxPushTemplate(notificationData), + trackerActivityClass, + broadcastReceiverClass + ) + } + + PushTemplateType.PRODUCT_CATALOG -> { + return ProductCatalogNotificationBuilder.construct( + context, + ProductCatalogPushTemplate(notificationData), + trackerActivityClass, + broadcastReceiverClass + ) + } + + PushTemplateType.PRODUCT_RATING -> { + return ProductRatingNotificationBuilder.construct( + context, + ProductRatingPushTemplate(notificationData), + trackerActivityClass, + broadcastReceiverClass + ) + } + + PushTemplateType.TIMER -> { + return TimerNotificationBuilder.construct( + context, + TimerPushTemplate(notificationData), + trackerActivityClass, + broadcastReceiverClass + ) + } + + PushTemplateType.MULTI_ICON -> { + return MultiIconNotificationBuilder.construct( + context, + MultiIconPushTemplate(notificationData), + trackerActivityClass, + ) + } + + PushTemplateType.UNKNOWN -> { + return LegacyNotificationBuilder.construct( + context, + BasicPushTemplate(notificationData), + trackerActivityClass + ) + } + } + } +} diff --git a/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/NotificationConstructionFailedException.kt b/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/NotificationConstructionFailedException.kt new file mode 100644 index 00000000..79662e13 --- /dev/null +++ b/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/NotificationConstructionFailedException.kt @@ -0,0 +1,19 @@ +/* + Copyright 2024 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. +*/ + +package com.adobe.marketing.mobile.notificationbuilder + +/** + * Exception indicating that construction of a push notification failed. + * + * @param message [String] containing the message for the new exception + */ +class NotificationConstructionFailedException(message: String) : Exception(message) diff --git a/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/NotificationPriority.kt b/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/NotificationPriority.kt new file mode 100644 index 00000000..059d2d35 --- /dev/null +++ b/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/NotificationPriority.kt @@ -0,0 +1,43 @@ +/* + Copyright 2024 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. +*/ + +package com.adobe.marketing.mobile.notificationbuilder + +import androidx.core.app.NotificationCompat + +enum class NotificationPriority(val value: Int, val stringValue: String) { + PRIORITY_DEFAULT(NotificationCompat.PRIORITY_DEFAULT, "PRIORITY_DEFAULT"), + PRIORITY_MIN(NotificationCompat.PRIORITY_MIN, "PRIORITY_MIN"), + PRIORITY_LOW(NotificationCompat.PRIORITY_LOW, "PRIORITY_LOW"), + PRIORITY_HIGH(NotificationCompat.PRIORITY_HIGH, "PRIORITY_HIGH"), + PRIORITY_MAX(NotificationCompat.PRIORITY_MAX, "PRIORITY_MAX"); + + companion object { + private val mapByString = values().associateBy { it.stringValue } + private val mapByValue = values().associateBy { it.value } + + /** + * Returns the [NotificationPriority] enum for the given [priorityString]. + * If the [priorityString] is null or not found, returns [PRIORITY_DEFAULT]. + */ + @JvmStatic + fun fromString(priorityString: String?): NotificationPriority = + priorityString?.let { mapByString[it] } ?: PRIORITY_DEFAULT + + /** + * Returns the [NotificationPriority] enum for the given [value]. + * If the [value] is null or not found, returns [PRIORITY_DEFAULT]. + */ + @JvmStatic + fun fromValue(value: Int?): NotificationPriority = + value?.let { mapByValue[it] } ?: PRIORITY_DEFAULT + } +} diff --git a/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/NotificationVisibility.kt b/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/NotificationVisibility.kt new file mode 100644 index 00000000..0cade139 --- /dev/null +++ b/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/NotificationVisibility.kt @@ -0,0 +1,44 @@ +/* + Copyright 2024 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. +*/ + +package com.adobe.marketing.mobile.notificationbuilder + +import androidx.core.app.NotificationCompat + +enum class NotificationVisibility(val value: Int, val stringValue: String) { + VISIBILITY_PRIVATE(NotificationCompat.VISIBILITY_PRIVATE, "PRIVATE"), + VISIBILITY_PUBLIC(NotificationCompat.VISIBILITY_PUBLIC, "PUBLIC"), + VISIBILITY_SECRET(NotificationCompat.VISIBILITY_SECRET, "SECRET"); + + companion object { + + private val mapString = NotificationVisibility.values().associateBy { it.stringValue } + private val mapValue = NotificationVisibility.values().associateBy { it.value } + + /** + * Returns the [NotificationVisibility] enum for the given [visibilityString]. + * If the [visibilityString] is null or not found, returns [VISIBILITY_PRIVATE]. + */ + @JvmStatic + fun fromString(visibilityString: String?): NotificationVisibility { + return visibilityString?.let { mapString[it] } ?: VISIBILITY_PRIVATE + } + + /** + * Returns the [NotificationVisibility] enum for the given [visibilityValue]. + * If the [visibilityValue] is null or not found, returns [VISIBILITY_PRIVATE]. + */ + @JvmStatic + fun fromValue(visibilityValue: Int?): NotificationVisibility { + return visibilityValue?.let { mapValue[it] } ?: VISIBILITY_PRIVATE + } + } +} diff --git a/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/PushTemplateConstants.kt b/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/PushTemplateConstants.kt new file mode 100644 index 00000000..4c5bb17e --- /dev/null +++ b/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/PushTemplateConstants.kt @@ -0,0 +1,200 @@ +/* + Copyright 2024 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. +*/ + +package com.adobe.marketing.mobile.notificationbuilder + +import java.util.concurrent.TimeUnit + +/** + * This object holds all constant values for handling out-of-the-box push template notifications + */ +object PushTemplateConstants { + internal const val LOG_TAG = "PushTemplates" + internal const val CACHE_BASE_DIR = "pushtemplates" + internal const val PUSH_IMAGE_CACHE = "pushimagecache" + + internal const val DEFAULT_DELETE_ICON_NAME = "cross" + + /** Enum to denote the type of action */ + internal enum class ActionType { + DEEPLINK, WEBURL, DISMISS, OPENAPP, NONE + } + + internal object ActionButtons { + internal const val LABEL = "label" + internal const val URI = "uri" + internal const val TYPE = "type" + } + + internal object RatingAction { + internal const val URI = "uri" + internal const val TYPE = "type" + } + + object NotificationAction { + const val DISMISSED = "Notification Dismissed" + const val CLICKED = "Notification Clicked" + const val INPUT_RECEIVED = "Input Received" + } + object TrackingKeys { + const val ACTION_ID = "actionId" + const val ACTION_URI = "actionUri" + } + + internal object DefaultValues { + // When no channel name is received from the push notification, this default channel name is used. + // This will appear in the notification settings for the app. + internal const val DEFAULT_CHANNEL_NAME = "General Notifications" + internal const val SILENT_CHANNEL_NAME = "Silent Notifications" + internal const val DEFAULT_CHANNEL_ID = "AEPSDKPushChannel" + internal const val SILENT_NOTIFICATION_CHANNEL_ID = "AEPSDKSilentPushChannel" + internal const val CAROUSEL_MAX_BITMAP_WIDTH = 300 + internal const val CAROUSEL_MAX_BITMAP_HEIGHT = 200 + internal const val AUTO_CAROUSEL_MODE = "auto" + internal const val DEFAULT_MANUAL_CAROUSEL_MODE = "default" + internal const val FILMSTRIP_CAROUSEL_MODE = "filmstrip" + internal const val CAROUSEL_MINIMUM_IMAGE_COUNT = 3 + internal const val MANUAL_CAROUSEL_START_INDEX = 0 + internal const val FILMSTRIP_CAROUSEL_CENTER_INDEX = 1 + internal const val NO_CENTER_INDEX_SET = -1 + internal const val INPUT_BOX_DEFAULT_REPLY_TEXT = "Reply" + internal const val PRODUCT_CATALOG_START_INDEX = 0 + internal const val PRODUCT_CATALOG_VERTICAL_LAYOUT = "vertical" + internal const val ICON_TEMPLATE_MIN_IMAGE_COUNT = 3 + internal const val ICON_TEMPLATE_MAX_IMAGE_COUNT = 5 + + // TODO: revisit this value. should cache time be configurable rather than have a static + // value? + internal val PUSH_NOTIFICATION_IMAGE_CACHE_EXPIRY_IN_MILLISECONDS: Long = + TimeUnit.DAYS.toMillis(3) // 3 days + } + + internal object MethodNames { + internal const val SET_BACKGROUND_COLOR = "setBackgroundColor" + internal const val SET_TEXT_COLOR = "setTextColor" + } + + internal object FriendlyViewNames { + internal const val NOTIFICATION_BACKGROUND = "notification background" + internal const val NOTIFICATION_TITLE = "notification title" + internal const val NOTIFICATION_BODY_TEXT = "notification body text" + internal const val CTA_BUTTON = "product catalog cta button" + internal const val TIMER_TEXT = "Timer Text" + } + + object PushPayloadKeys { + const val TEMPLATE_TYPE = "adb_template_type" + const val TITLE = "adb_title" + const val BODY = "adb_body" + const val SOUND = "adb_sound" + const val BADGE_COUNT = "adb_n_count" + const val VISIBILITY = "adb_n_visibility" + const val PRIORITY = "adb_n_priority" + const val CHANNEL_ID = "adb_channel_id" + const val LEGACY_SMALL_ICON = "adb_icon" + const val SMALL_ICON = "adb_small_icon" + const val LARGE_ICON = "adb_large_icon" + const val IMAGE_URL = "adb_image" + const val TAG = "adb_tag" + const val TICKER = "adb_ticker" + const val STICKY = "adb_sticky" + const val ACTION_TYPE = "adb_a_type" + const val ACTION_URI = "adb_uri" + const val ACTION_BUTTONS = "adb_act" + const val VERSION = "adb_version" + const val CAROUSEL_LAYOUT = "adb_car_layout" + const val CAROUSEL_ITEMS = "adb_items" + const val EXPANDED_BODY_TEXT = "adb_body_ex" + const val BODY_TEXT_COLOR = "adb_clr_body" + const val TITLE_TEXT_COLOR = "adb_clr_title" + const val SMALL_ICON_COLOR = "adb_clr_icon" + const val BACKGROUND_COLOR = "adb_clr_bg" + const val REMIND_LATER_TEXT = "adb_rem_txt" + const val REMIND_LATER_TIMESTAMP = "adb_rem_ts" + const val REMIND_LATER_DURATION = "adb_rem_sec" + const val CAROUSEL_OPERATION_MODE = "adb_car_mode" + const val INPUT_BOX_HINT = "adb_input_txt" + const val INPUT_BOX_FEEDBACK_TEXT = "adb_feedback_txt" + const val INPUT_BOX_FEEDBACK_IMAGE = "adb_feedback_img" + const val INPUT_BOX_RECEIVER_NAME = "adb_input_receiver" + const val ZERO_BEZEL_COLLAPSED_STYLE = "adb_col_style" + const val CATALOG_CTA_BUTTON_TEXT = "adb_cta_txt" + const val CATALOG_CTA_BUTTON_COLOR = "adb_cta_clr" + const val CATALOG_CTA_BUTTON_TEXT_COLOR = "adb_cta_txt_clr" + const val CATALOG_CTA_BUTTON_URI = "adb_cta_uri" + const val CATALOG_LAYOUT = "adb_display" + const val CATALOG_ITEMS = "adb_items" + const val RATING_UNSELECTED_ICON = "adb_rate_unselected_icon" + const val RATING_SELECTED_ICON = "adb_rate_selected_icon" + const val RATING_ACTIONS = "adb_rate_act" + + const val MULTI_ICON_ITEMS = "adb_items" + const val MULTI_ICON_CLOSE_BUTTON = "adb_cancel_image" + + internal object TimerKeys { + const val ALTERNATE_TITLE = "adb_title_alt" + const val ALTERNATE_BODY = "adb_body_alt" + const val ALTERNATE_EXPANDED_BODY = "adb_body_ex_alt" + const val TIMER_COLOR = "adb_clr_timer" + const val ALTERNATE_IMAGE = "adb_image_alt" + const val TIMER_DURATION = "adb_tmr_dur" + const val TIMER_END_TIME = "adb_tmr_end" + } + } + + internal object CarouselItemKeys { + internal const val IMAGE = "img" + internal const val TEXT = "txt" + internal const val URI = "uri" + } + + internal object CatalogItemKeys { + internal const val TITLE = "title" + internal const val BODY = "body" + internal const val IMAGE = "img" + internal const val PRICE = "price" + internal const val URI = "uri" + } + + internal object CatalogActionIds { + internal const val CTA_BUTTON_CLICKED = "cta_button_clicked" + internal const val PRODUCT_IMAGE_CLICKED = "product_image_clicked" + } + + internal object ProductRatingKeys { + internal const val RATING_UNSELECTED = -1 + } + + object IntentActions { + const val FILMSTRIP_LEFT_CLICKED = "filmstrip_left" + const val FILMSTRIP_RIGHT_CLICKED = "filmstrip_right" + const val REMIND_LATER_CLICKED = "remind_clicked" + const val MANUAL_CAROUSEL_LEFT_CLICKED = "manual_left" + const val MANUAL_CAROUSEL_RIGHT_CLICKED = "manual_right" + const val INPUT_RECEIVED = "input_received" + const val CATALOG_THUMBNAIL_CLICKED = "thumbnail_clicked" + const val RATING_ICON_CLICKED = "rating_icon_clicked" + const val SCHEDULED_NOTIFICATION_BROADCAST = "scheduled_notification_broadcast" + const val TIMER_EXPIRED = "timer_expired" + } + + object IntentKeys { + const val CENTER_IMAGE_INDEX = "centerImageIndex" + const val CATALOG_ITEM_INDEX = "catalogItemIndex" + const val RATING_SELECTED = "ratingSelected" + } + + internal object MultiIconTemplateKeys { + const val IMG = "img" + const val URI = "uri" + const val TYPE = "type" + } +} diff --git a/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/RemindLaterHandler.kt b/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/RemindLaterHandler.kt new file mode 100644 index 00000000..a82fd8a8 --- /dev/null +++ b/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/RemindLaterHandler.kt @@ -0,0 +1,92 @@ +/* + Copyright 2024 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. +*/ + +package com.adobe.marketing.mobile.notificationbuilder + +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Intent +import androidx.core.app.NotificationManagerCompat +import com.adobe.marketing.mobile.notificationbuilder.internal.PendingIntentUtils +import com.adobe.marketing.mobile.services.Log +import com.adobe.marketing.mobile.services.ServiceProvider +import com.adobe.marketing.mobile.util.TimeUtils + +/** + * Public facing object to handle the remind later intent. + */ +object RemindLaterHandler { + private const val SELF_TAG = "RemindLaterHandler" + + /** + * Handles the remind later intent by scheduling a [PendingIntent] to the [broadcastReceiverClass] + * which will be fired at a time specified in the [remindLaterIntent]. + * + * Once the PendingIntent is fired, the [broadcastReceiverClass] is responsible for + * reconstructing the notification and displaying it. + * + * @param remindLaterIntent [Intent] containing the data needed to schedule and recreate the notification + * @param broadcastReceiverClass [Class] of the [BroadcastReceiver] that will be fired when the [PendingIntent] resolves at a later time + */ + @Throws(NotificationConstructionFailedException::class, IllegalArgumentException::class) + @JvmStatic + fun handleRemindIntent( + remindLaterIntent: Intent, + broadcastReceiverClass: Class? + ) { + val context = ServiceProvider.getInstance().appContextService.applicationContext + ?: throw NotificationConstructionFailedException("Application context is null, cannot schedule notification for later.") + + // get the time for remind later from the intent extras + val intentExtras = remindLaterIntent.extras + ?: throw NotificationConstructionFailedException("Intent extras are null, cannot schedule notification for later.") + val remindLaterTimestamp = + intentExtras.getString(PushTemplateConstants.PushPayloadKeys.REMIND_LATER_TIMESTAMP)?.toLongOrNull() ?: 0 + val remindLaterDuration = + intentExtras.getString(PushTemplateConstants.PushPayloadKeys.REMIND_LATER_DURATION)?.toLongOrNull() ?: 0 + + // calculate difference in fire date from the current date if timestamp is provided + val secondsUntilFireDate: Long = if (remindLaterDuration > 0) remindLaterDuration + else remindLaterTimestamp - TimeUtils.getUnixTimeInSeconds() + + val notificationManager = NotificationManagerCompat.from(context) + val tag = intentExtras.getString(PushTemplateConstants.PushPayloadKeys.TAG) + + // if fire date is greater than 0 then we want to schedule a reminder notification. + if (secondsUntilFireDate <= 0) { + tag?.let { notificationManager.cancel(tag.hashCode()) } + throw IllegalArgumentException("Remind later timestamp or duration is less than or equal to current timestamp, cannot schedule notification for later.") + } + Log.trace(PushTemplateConstants.LOG_TAG, SELF_TAG, "Remind later pressed, will reschedule the notification to be displayed $secondsUntilFireDate seconds from now") + + // calculate the trigger time + val triggerTimeInSeconds: Long = if (remindLaterDuration > 0) remindLaterDuration + TimeUtils.getUnixTimeInSeconds() + else remindLaterTimestamp + + // schedule a pending intent to be broadcast at the specified timestamp + if (broadcastReceiverClass == null) { + Log.warning( + PushTemplateConstants.LOG_TAG, + SELF_TAG, + "Broadcast receiver class is null, cannot schedule notification for later." + ) + tag?.let { notificationManager.cancel(tag.hashCode()) } + return + } + val scheduledIntent = Intent(PushTemplateConstants.IntentActions.SCHEDULED_NOTIFICATION_BROADCAST) + scheduledIntent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) + scheduledIntent.putExtras(intentExtras) + PendingIntentUtils.scheduleNotification(context, scheduledIntent, broadcastReceiverClass, triggerTimeInSeconds) + + // cancel the displayed notification + tag?.let { notificationManager.cancel(tag.hashCode()) } + } +} diff --git a/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/PendingIntentUtils.kt b/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/PendingIntentUtils.kt new file mode 100644 index 00000000..6b6e6c55 --- /dev/null +++ b/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/PendingIntentUtils.kt @@ -0,0 +1,149 @@ +/* + Copyright 2024 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. +*/ + +package com.adobe.marketing.mobile.notificationbuilder.internal + +import android.app.Activity +import android.app.AlarmManager +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.Bundle +import com.adobe.marketing.mobile.notificationbuilder.PushTemplateConstants +import com.adobe.marketing.mobile.services.Log +import java.util.Random + +internal object PendingIntentUtils { + + private const val SELF_TAG = "PendingIntentUtils" + + internal fun scheduleNotification( + context: Context, + scheduledIntent: Intent, + broadcastReceiverClass: Class?, + triggerAtSeconds: Long, + ) { + broadcastReceiverClass?.let { + scheduledIntent.setClass(context, broadcastReceiverClass) + } + + val pendingIntent: PendingIntent = PendingIntent.getBroadcast( + context, + Random().nextInt(), + scheduledIntent, + PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + + val alarmManager = + context.getSystemService(Context.ALARM_SERVICE) as AlarmManager? ?: return + + if (isExactAlarmsAllowed(alarmManager)) { + Log.trace( + PushTemplateConstants.LOG_TAG, + SELF_TAG, + "Exact alarms are permitted, scheduling an exact alarm for the current notification." + ) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAtSeconds * 1000, pendingIntent) + } else { + alarmManager.setExact( + AlarmManager.RTC_WAKEUP, + triggerAtSeconds * 1000, + pendingIntent + ) + } + } else { + // schedule an inexact alarm for the current notification + Log.trace( + PushTemplateConstants.LOG_TAG, + SELF_TAG, + "Exact alarms are not permitted, scheduling an inexact alarm for the current notification." + ) + alarmManager.setAndAllowWhileIdle( + AlarmManager.RTC_WAKEUP, + triggerAtSeconds * 1000, + pendingIntent + ) + } + } + + /** + * Checks if exact alarms are allowed on the device + * + * @param alarmManager [AlarmManager] instance + * @return true if exact alarms are allowed, false otherwise + */ + internal fun isExactAlarmsAllowed(alarmManager: AlarmManager?): Boolean { + return Build.VERSION.SDK_INT < Build.VERSION_CODES.S || + alarmManager?.canScheduleExactAlarms() ?: false + } + + /** + * Creates a pending intent for a notification. + * + * @param context the application [Context] + * @param trackerActivityClass the [Class] of the activity to set in the created pending intent for tracking purposes + * notification + * @param actionUri the action uri + * @param actionID the action ID + * @param intentExtras the [Bundle] containing the extras to be added to the intent + * @return the created [PendingIntent] + */ + internal fun createPendingIntentForTrackerActivity( + context: Context, + trackerActivityClass: Class?, + actionUri: String?, + actionID: String?, + intentExtras: Bundle? + ): PendingIntent? { + val intent = Intent(PushTemplateConstants.NotificationAction.CLICKED) + trackerActivityClass?.let { + intent.setClass(context.applicationContext, trackerActivityClass) + } + intent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP) + // todo revisit if all data is needed for click actions + intentExtras?.let { intent.putExtras(intentExtras) } + addActionDetailsToIntent( + intent, + actionUri, + actionID + ) + + return PendingIntent.getActivity( + context, + Random().nextInt(), + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + } + + /** + * Adds action details to the provided [Intent]. + * + * @param intent the intent + * @param actionUri [String] containing the action uri + * @param actionId `String` containing the action ID + */ + private fun addActionDetailsToIntent( + intent: Intent, + actionUri: String?, + actionId: String? + ) { + if (!actionUri.isNullOrEmpty()) { + intent.putExtra(PushTemplateConstants.TrackingKeys.ACTION_URI, actionUri) + } + if (!actionId.isNullOrEmpty()) { + intent.putExtra(PushTemplateConstants.TrackingKeys.ACTION_ID, actionId) + } + } +} diff --git a/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/PushTemplateImageUtils.kt b/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/PushTemplateImageUtils.kt new file mode 100644 index 00000000..8144e654 --- /dev/null +++ b/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/PushTemplateImageUtils.kt @@ -0,0 +1,318 @@ +/* + Copyright 2024 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. +*/ + +package com.adobe.marketing.mobile.notificationbuilder.internal + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Matrix +import android.graphics.RectF +import com.adobe.marketing.mobile.notificationbuilder.PushTemplateConstants +import com.adobe.marketing.mobile.notificationbuilder.PushTemplateConstants.LOG_TAG +import com.adobe.marketing.mobile.services.HttpConnecting +import com.adobe.marketing.mobile.services.HttpMethod +import com.adobe.marketing.mobile.services.Log +import com.adobe.marketing.mobile.services.NetworkCallback +import com.adobe.marketing.mobile.services.NetworkRequest +import com.adobe.marketing.mobile.services.ServiceProvider +import com.adobe.marketing.mobile.services.caching.CacheEntry +import com.adobe.marketing.mobile.services.caching.CacheExpiry +import com.adobe.marketing.mobile.services.caching.CacheService +import com.adobe.marketing.mobile.util.UrlUtils +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.IOException +import java.io.InputStream +import java.net.HttpURLConnection +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger + +/** + * Utility functions to assist in downloading and caching images for push template notifications. + */ + +internal object PushTemplateImageUtils { + private const val SELF_TAG = "PushTemplateImageUtil" + private const val FULL_BITMAP_QUALITY = 100 + private const val DOWNLOAD_TIMEOUT_SECS = 10 + + /** + * Downloads and caches images provided in the [urlList]. Prior to downloading, the image url + * is used to retrieve a previously cached image using [CacheService]. + * If a valid cache result is returned then no image is downloaded. + * If no cache result is returned, a call to [downloadImage] is made to download then cache the image. + * + * This is a blocking method that returns only after the download for all images + * have finished either by failing or successfully downloading, or the timeout has been reached. + * + * @param urlList [String] containing an image asset url + * @return [Int] number of images that were found in cache or successfully downloaded + */ + internal fun cacheImages( + urlList: List + ): Int { + val assetCacheLocation = getAssetCacheLocation() + if (urlList.isEmpty() || assetCacheLocation.isNullOrEmpty()) { + return 0 + } + + val cacheService = ServiceProvider.getInstance().cacheService + val downloadedImageCount = AtomicInteger(0) + val latchAborted = AtomicBoolean(false) + val latch = CountDownLatch(urlList.size) + for (url in urlList) { + if (url == null || !UrlUtils.isValidUrl(url)) { + latch.countDown() + continue + } + + val cacheResult = cacheService[assetCacheLocation, url] + if (cacheResult != null) { + Log.trace( + LOG_TAG, + SELF_TAG, + "Found cached image for $url" + ) + downloadedImageCount.incrementAndGet() + latch.countDown() + continue + } + + downloadImage(url) { connection -> + if (!latchAborted.get()) { + val image = handleDownloadResponse(url, connection) + // scale down the bitmap to 300dp x 200dp as we don't want to use a full + // size image due to memory constraints + image?.let { + val pushImage = scaleBitmap(it) + // write bitmap to cache + try { + bitmapToInputStream(pushImage).use { bitmapInputStream -> + cacheBitmapInputStream( + cacheService, + bitmapInputStream, + url + ) + } + downloadedImageCount.incrementAndGet() + } catch (exception: IOException) { + Log.warning( + LOG_TAG, + SELF_TAG, + "Exception occurred creating an input stream from a bitmap for {$url}: ${exception.localizedMessage}." + ) + } + } + latch.countDown() + } + connection?.close() + } + } + try { + if (latch.await(DOWNLOAD_TIMEOUT_SECS.toLong(), TimeUnit.SECONDS)) { + Log.trace( + LOG_TAG, + SELF_TAG, + "All image downloads have completed." + ) + } else { + Log.warning( + LOG_TAG, + SELF_TAG, + "Timed out waiting for image downloads to complete." + ) + latchAborted.set(true) + } + } catch (e: InterruptedException) { + Log.warning( + LOG_TAG, + SELF_TAG, + "Interrupted while waiting for image downloads to complete: ${e.localizedMessage}" + ) + latchAborted.set(true) + } + return downloadedImageCount.get() + } + + /** + * Initiates a network request to download the image provided by the url `String`. + * + * @param url [String] containing the image url to download + * @param completionCallback callback to be invoked with the [HttpConnecting] object + * when download is complete + */ + private fun downloadImage( + url: String, + completionCallback: (HttpConnecting?) -> Unit + ) { + val networkRequest = NetworkRequest( + url, + HttpMethod.GET, + null, + null, + DOWNLOAD_TIMEOUT_SECS, + DOWNLOAD_TIMEOUT_SECS + ) + + val networkCallback = NetworkCallback { connection: HttpConnecting? -> + completionCallback.invoke(connection) + } + + ServiceProvider.getInstance() + .networkService + .connectAsync(networkRequest, networkCallback) + } + + /** + * Retrieves an image from the cache using the provided url `String`. + * + * @param url [String] containing the image url to retrieve from cache + * @return [Bitmap] containing the image retrieved from cache, or `null` if no image is found + */ + internal fun getCachedImage(url: String?): Bitmap? { + val assetCacheLocation = getAssetCacheLocation() + if (url == null || !UrlUtils.isValidUrl(url) || assetCacheLocation.isNullOrEmpty()) { + return null + } + val cacheResult = ServiceProvider.getInstance().cacheService[assetCacheLocation, url] + if (cacheResult == null) { + Log.warning(LOG_TAG, SELF_TAG, "Image not found in cache for $url") + return null + } + Log.trace(LOG_TAG, SELF_TAG, "Found cached image for $url") + return BitmapFactory.decodeStream(cacheResult.data) + } + + private fun handleDownloadResponse(url: String?, connection: HttpConnecting?): Bitmap? { + if (connection == null) { + Log.warning( + LOG_TAG, + SELF_TAG, + "Failed to download push notification image from url ($url), received a null connection." + ) + return null + } + if ((connection.responseCode != HttpURLConnection.HTTP_OK)) { + Log.debug( + LOG_TAG, + SELF_TAG, + "Failed to download push notification image from url ($url). Response code was: ${connection.responseCode}." + ) + return null + } + val bitmap = BitmapFactory.decodeStream(connection.inputStream) + bitmap?.let { + Log.trace( + LOG_TAG, + SELF_TAG, + "Downloaded push notification image from url ($url)" + ) + } + return bitmap + } + + /** + * Converts a [Bitmap] into an [InputStream] to be used in caching images. + * + * @param bitmap [Bitmap] to be converted into an [InputStream] + * @return an `InputStream` created from the provided bitmap + */ + private fun bitmapToInputStream(bitmap: Bitmap): InputStream { + ByteArrayOutputStream().use { + bitmap.compress(Bitmap.CompressFormat.PNG, FULL_BITMAP_QUALITY, it) + val bitmapData = it.toByteArray() + return ByteArrayInputStream(bitmapData) + } + } + + /** + * Writes the provided [InputStream] to the downloaded push template image [assetCacheLocation]. + * + * @param cacheService [CacheService] the AEPSDK cache service + * @param bitmapInputStream [InputStream] created from a download [Bitmap] + * @param imageUrl [String] containing the image url to be used a cache key + */ + private fun cacheBitmapInputStream( + cacheService: CacheService, + bitmapInputStream: InputStream, + imageUrl: String + ) { + Log.trace( + LOG_TAG, + SELF_TAG, + "Caching image downloaded from $imageUrl." + ) + getAssetCacheLocation()?.let { + // cache push notification images for 3 days + val cacheEntry = CacheEntry( + bitmapInputStream, + CacheExpiry.after( + PushTemplateConstants.DefaultValues.PUSH_NOTIFICATION_IMAGE_CACHE_EXPIRY_IN_MILLISECONDS + ), + null + ) + cacheService[it, imageUrl] = cacheEntry + } + } + + /** + * Scales a downloaded [Bitmap] to a maximum width and height of 300dp x 200dp. + * The scaling is done using a [Matrix] object to maintain the aspect ratio of the original + * image. + * + * @param downloadedBitmap [Bitmap] to be scaled + * @return [Bitmap] containing the scaled image + */ + private fun scaleBitmap(downloadedBitmap: Bitmap): Bitmap { + val matrix = Matrix() + matrix.setRectToRect( + RectF(0f, 0f, downloadedBitmap.width.toFloat(), downloadedBitmap.height.toFloat()), + RectF( + 0f, + 0f, + PushTemplateConstants.DefaultValues.CAROUSEL_MAX_BITMAP_WIDTH.toFloat(), + PushTemplateConstants.DefaultValues.CAROUSEL_MAX_BITMAP_HEIGHT.toFloat() + ), + Matrix.ScaleToFit.CENTER + ) + return Bitmap.createBitmap( + downloadedBitmap, + 0, + 0, + downloadedBitmap.width, + downloadedBitmap.height, + matrix, + true + ) + } + + /** + * Retrieves the asset cache location to use for downloaded push template images. + * + * @return [String] containing the asset cache location to use for storing downloaded push template images. + */ + internal fun getAssetCacheLocation(): String? { + val deviceInfoService = ServiceProvider.getInstance().deviceInfoService + ?: return null + val applicationCacheDir = deviceInfoService.applicationCacheDir + return if ((applicationCacheDir == null)) null else ( + ( + applicationCacheDir + .toString() + File.separator + + PushTemplateConstants.CACHE_BASE_DIR + ) + File.separator + + PushTemplateConstants.PUSH_IMAGE_CACHE + ) + } +} diff --git a/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/PushTemplateType.kt b/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/PushTemplateType.kt new file mode 100644 index 00000000..659dca1c --- /dev/null +++ b/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/PushTemplateType.kt @@ -0,0 +1,49 @@ +/* + Copyright 2024 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. +*/ + +package com.adobe.marketing.mobile.notificationbuilder.internal + +/** + * Enum class representing the different types of out-of-the-box push templates. + */ +internal enum class PushTemplateType(val value: String) { + BASIC("basic"), + CAROUSEL("car"), + INPUT_BOX("input"), + ZERO_BEZEL("zb"), + PRODUCT_RATING("rate"), + PRODUCT_CATALOG("cat"), + MULTI_ICON("icon"), + TIMER("timer"), + UNKNOWN("unknown"); + + companion object { + /** + * Returns the [PushTemplateType] for the given string value. + * @param value the string value to convert to [PushTemplateType] + * @return the [PushTemplateType] for the given string value + */ + @JvmStatic + fun fromString(value: String?): PushTemplateType { + return when (value) { + "basic" -> BASIC + "car" -> CAROUSEL + "input" -> INPUT_BOX + "zb" -> ZERO_BEZEL + "cat" -> PRODUCT_CATALOG + "rate" -> PRODUCT_RATING + "icon" -> MULTI_ICON + "timer" -> TIMER + else -> UNKNOWN + } + } + } +} diff --git a/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/builders/AEPPushNotificationBuilder.kt b/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/builders/AEPPushNotificationBuilder.kt new file mode 100644 index 00000000..31b078a9 --- /dev/null +++ b/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/builders/AEPPushNotificationBuilder.kt @@ -0,0 +1,131 @@ +/* + Copyright 2024 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. +*/ + +package com.adobe.marketing.mobile.notificationbuilder.internal.builders + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.os.Build +import android.widget.RemoteViews +import androidx.core.app.NotificationCompat +import com.adobe.marketing.mobile.notificationbuilder.NotificationConstructionFailedException +import com.adobe.marketing.mobile.notificationbuilder.R +import com.adobe.marketing.mobile.notificationbuilder.internal.extensions.setNotificationBackgroundColor +import com.adobe.marketing.mobile.notificationbuilder.internal.extensions.setNotificationBodyTextColor +import com.adobe.marketing.mobile.notificationbuilder.internal.extensions.setNotificationClickAction +import com.adobe.marketing.mobile.notificationbuilder.internal.extensions.setNotificationDeleteAction +import com.adobe.marketing.mobile.notificationbuilder.internal.extensions.setNotificationTitleTextColor +import com.adobe.marketing.mobile.notificationbuilder.internal.extensions.setRemoteViewImage +import com.adobe.marketing.mobile.notificationbuilder.internal.extensions.setSmallIcon +import com.adobe.marketing.mobile.notificationbuilder.internal.extensions.setSound +import com.adobe.marketing.mobile.notificationbuilder.internal.templates.AEPPushTemplate + +// TODO: The utilities provided by this builder assumes the id's for various common elements (R.id.basic_small_layout, +// R.id.notification_title, R.id.notification_body_expanded) are the same across templates. +// We will need to figure out a way to enforce this somehow either programmatically, structurally in the layout or via documentation. +internal object AEPPushNotificationBuilder { + @Throws(NotificationConstructionFailedException::class) + fun construct( + context: Context, + pushTemplate: AEPPushTemplate, + channelIdToUse: String, + trackerActivityClass: Class?, + smallLayout: RemoteViews, + expandedLayout: RemoteViews, + containerLayoutViewId: Int + ): NotificationCompat.Builder { + + // set the title and body text on the notification + val titleText = pushTemplate.title + val smallBodyText = pushTemplate.body + val expandedBodyText = pushTemplate.expandedBodyText + smallLayout.setTextViewText(R.id.notification_title, titleText) + smallLayout.setTextViewText(R.id.notification_body, smallBodyText) + expandedLayout.setTextViewText(R.id.notification_title, titleText) + expandedLayout.setTextViewText(R.id.notification_body_expanded, expandedBodyText) + + // set custom colors on the notification background, title text, and body text + smallLayout.setNotificationBackgroundColor( + pushTemplate.backgroundColor, + R.id.basic_small_layout + ) + + expandedLayout.setNotificationBackgroundColor( + pushTemplate.backgroundColor, + containerLayoutViewId + ) + + smallLayout.setNotificationTitleTextColor( + pushTemplate.titleTextColor, + R.id.notification_title + ) + + expandedLayout.setNotificationTitleTextColor( + pushTemplate.titleTextColor, + R.id.notification_title + ) + + smallLayout.setNotificationBodyTextColor( + pushTemplate.bodyTextColor, + R.id.notification_body + ) + + expandedLayout.setNotificationBodyTextColor( + pushTemplate.bodyTextColor, + R.id.notification_body_expanded + ) + + // set a large icon if one is present + smallLayout.setRemoteViewImage(pushTemplate.largeIcon, R.id.large_icon) + expandedLayout.setRemoteViewImage(pushTemplate.largeIcon, R.id.large_icon) + + val builder = NotificationCompat.Builder(context, channelIdToUse) + .setTicker(pushTemplate.ticker) + .setNumber(pushTemplate.badgeCount) + .setStyle(NotificationCompat.DecoratedCustomViewStyle()) + .setCustomContentView(smallLayout) + .setCustomBigContentView(expandedLayout) + // small icon must be present, otherwise the notification will not be displayed. + .setSmallIcon(context, pushTemplate.smallIcon, pushTemplate.smallIconColor) + // set notification visibility + .setVisibility(pushTemplate.visibility.value) + .setNotificationClickAction( + context, + trackerActivityClass, + pushTemplate.actionUri, + pushTemplate.data.getBundle() + ) + .setNotificationDeleteAction(context, trackerActivityClass) + + // if not from intent, set custom sound, note this applies to API 25 and lower only as + // API 26 and up set the sound on the notification channel + if (!pushTemplate.isFromIntent) { + builder.setSound(context, pushTemplate.sound) + } + + // if API level is below 26 (prior to notification channels) then notification priority is + // set on the notification builder + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + builder.setPriority(NotificationCompat.PRIORITY_HIGH) + .setVibrate(LongArray(0)) // hack to enable heads up notifications as a HUD style + // notification requires a tone or vibration + } + return builder + } + + internal fun createIntent(action: String, template: AEPPushTemplate): Intent { + val intent = Intent(action) + intent.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP + intent.putExtras(template.data.getBundle()) + return intent + } +} diff --git a/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/builders/AutoCarouselNotificationBuilder.kt b/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/builders/AutoCarouselNotificationBuilder.kt new file mode 100644 index 00000000..2b6aabf1 --- /dev/null +++ b/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/builders/AutoCarouselNotificationBuilder.kt @@ -0,0 +1,151 @@ +/* + Copyright 2024 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. +*/ + +package com.adobe.marketing.mobile.notificationbuilder.internal.builders + +import android.app.Activity +import android.app.NotificationManager +import android.content.BroadcastReceiver +import android.content.Context +import android.graphics.Bitmap +import android.widget.RemoteViews +import androidx.annotation.VisibleForTesting +import androidx.core.app.NotificationCompat +import com.adobe.marketing.mobile.notificationbuilder.PushTemplateConstants +import com.adobe.marketing.mobile.notificationbuilder.PushTemplateConstants.LOG_TAG +import com.adobe.marketing.mobile.notificationbuilder.R +import com.adobe.marketing.mobile.notificationbuilder.internal.PushTemplateImageUtils +import com.adobe.marketing.mobile.notificationbuilder.internal.extensions.createNotificationChannelIfRequired +import com.adobe.marketing.mobile.notificationbuilder.internal.extensions.setRemoteViewClickAction +import com.adobe.marketing.mobile.notificationbuilder.internal.templates.AutoCarouselPushTemplate +import com.adobe.marketing.mobile.notificationbuilder.internal.templates.CarouselPushTemplate +import com.adobe.marketing.mobile.services.Log + +/** + * Object responsible for constructing a [NotificationCompat.Builder] object containing a auto carousel push template notification. + */ +internal object AutoCarouselNotificationBuilder { + private const val SELF_TAG = "AutoCarouselNotificationBuilder" + + fun construct( + context: Context, + pushTemplate: AutoCarouselPushTemplate, + trackerActivityClass: Class?, + broadcastReceiverClass: Class?, + ): NotificationCompat.Builder { + Log.trace(LOG_TAG, SELF_TAG, "Building an auto carousel template push notification.") + + val packageName = context.packageName + val smallLayout = RemoteViews(packageName, R.layout.push_template_collapsed) + val expandedLayout = RemoteViews(packageName, R.layout.push_template_auto_carousel) + + // load images into the carousel + val downloadedImageCount = PushTemplateImageUtils.cacheImages( + pushTemplate.carouselItems.map { it.imageUri } + ) + + // fallback to a basic push template notification builder if less than 3 images were able to be downloaded + if (downloadedImageCount < PushTemplateConstants.DefaultValues.CAROUSEL_MINIMUM_IMAGE_COUNT) { + Log.warning(LOG_TAG, SELF_TAG, "Less than 3 images are available for the auto carousel push template, falling back to a basic push template.") + return BasicNotificationBuilder.fallbackToBasicNotification( + context, + trackerActivityClass, + broadcastReceiverClass, + pushTemplate.data, + ) + } + + // load images into the carousel + populateAutoCarouselImages( + context, + trackerActivityClass, + expandedLayout, + pushTemplate, + pushTemplate.carouselItems, + packageName + ) + + val notificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + // Create the notification channel if needed + val channelIdToUse = + notificationManager.createNotificationChannelIfRequired(context, pushTemplate) + + // create the notification builder with the common settings applied + return AEPPushNotificationBuilder.construct( + context, + pushTemplate, + channelIdToUse, + trackerActivityClass, + smallLayout, + expandedLayout, + R.id.carousel_container_layout + ) + } + + /** + * Populates the images for a automatic carousel push template. + * + * @param context the current [Context] of the application + * @param trackerActivityClass the [Class] of the activity that will be used for tracking interactions with the carousel item + * @param expandedLayout the [RemoteViews] containing the expanded layout of the notification + * @param pushTemplate the [CarouselPushTemplate] object containing the push template data + * @param items the list of [CarouselPushTemplate.CarouselItem] objects to be displayed in the carousel + * @param packageName the `String` name of the application package used to locate the layout resources + * @return a [List] of downloaded image URIs + */ + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun populateAutoCarouselImages( + context: Context, + trackerActivityClass: Class?, + expandedLayout: RemoteViews, + pushTemplate: CarouselPushTemplate, + items: MutableList, + packageName: String? + ): List { + val downloadedImageUris = mutableListOf() + for (item: CarouselPushTemplate.CarouselItem in items) { + val imageUri: String = item.imageUri + val pushImage: Bitmap? = PushTemplateImageUtils.getCachedImage(imageUri) + if (pushImage == null) { + Log.trace( + LOG_TAG, + SELF_TAG, + "Failed to retrieve an image from $imageUri, will not create a new carousel item." + ) + continue + } + val carouselItem = RemoteViews(packageName, R.layout.push_template_carousel_item) + downloadedImageUris.add(imageUri) + carouselItem.setImageViewBitmap(R.id.carousel_item_image_view, pushImage) + carouselItem.setTextViewText(R.id.carousel_item_caption, item.captionText) + + // assign a click action pending intent for each carousel item if we have a tracker activity + trackerActivityClass?.let { + val interactionUri = item.interactionUri ?: pushTemplate.actionUri + carouselItem.setRemoteViewClickAction( + context, + trackerActivityClass, + R.id.carousel_item_image_view, + interactionUri, + null, + pushTemplate.data.getBundle() + ) + } + + // add the carousel item to the view flipper + expandedLayout.addView(R.id.auto_carousel_view_flipper, carouselItem) + } + + return downloadedImageUris + } +} diff --git a/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/builders/BasicNotificationBuilder.kt b/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/builders/BasicNotificationBuilder.kt new file mode 100644 index 00000000..a9b00bae --- /dev/null +++ b/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/builders/BasicNotificationBuilder.kt @@ -0,0 +1,158 @@ +/* + Copyright 2024 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. +*/ + +package com.adobe.marketing.mobile.notificationbuilder.internal.builders + +import android.app.Activity +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.widget.RemoteViews +import androidx.annotation.VisibleForTesting +import androidx.core.app.NotificationCompat +import com.adobe.marketing.mobile.notificationbuilder.NotificationConstructionFailedException +import com.adobe.marketing.mobile.notificationbuilder.PushTemplateConstants +import com.adobe.marketing.mobile.notificationbuilder.PushTemplateConstants.LOG_TAG +import com.adobe.marketing.mobile.notificationbuilder.PushTemplateConstants.PushPayloadKeys +import com.adobe.marketing.mobile.notificationbuilder.R +import com.adobe.marketing.mobile.notificationbuilder.internal.extensions.addActionButtons +import com.adobe.marketing.mobile.notificationbuilder.internal.extensions.createNotificationChannelIfRequired +import com.adobe.marketing.mobile.notificationbuilder.internal.extensions.setRemoteViewImage +import com.adobe.marketing.mobile.notificationbuilder.internal.templates.BasicPushTemplate +import com.adobe.marketing.mobile.notificationbuilder.internal.util.NotificationData +import com.adobe.marketing.mobile.services.Log + +/** + * Object responsible for constructing a [NotificationCompat.Builder] object containing a basic push template notification. + */ +internal object BasicNotificationBuilder { + private const val SELF_TAG = "BasicNotificationBuilder" + + @Throws(NotificationConstructionFailedException::class) + fun construct( + context: Context, + pushTemplate: BasicPushTemplate, + trackerActivityClass: Class?, + broadcastReceiverClass: Class? + ): NotificationCompat.Builder { + Log.trace(LOG_TAG, SELF_TAG, "Building a basic template push notification.") + val packageName = context.packageName + val smallLayout = RemoteViews(packageName, R.layout.push_template_collapsed) + val expandedLayout = RemoteViews(packageName, R.layout.push_template_expanded) + + val notificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val channelIdToUse: String = + notificationManager.createNotificationChannelIfRequired(context, pushTemplate) + + // create the notification builder with the common settings applied + val notificationBuilder = AEPPushNotificationBuilder.construct( + context, + pushTemplate, + channelIdToUse, + trackerActivityClass, + smallLayout, + expandedLayout, + R.id.basic_expanded_layout + ) + + // set the image on the notification + expandedLayout.setRemoteViewImage( + pushTemplate.imageUrl, + R.id.expanded_template_image, + ) + + // add any action buttons defined for the notification + notificationBuilder.addActionButtons( + context, + trackerActivityClass, + pushTemplate.actionButtonsList, + pushTemplate.data.getBundle() + ) + + // add a remind later button if we have a label and an epoch or delay timestamp + pushTemplate.remindLaterText?.let { remindLaterText -> + if (pushTemplate.remindLaterTimestamp != null || + pushTemplate.remindLaterDuration != null + ) { + val remindIntent = createRemindPendingIntent( + context, + broadcastReceiverClass, + channelIdToUse, + pushTemplate + ) + notificationBuilder.addAction(0, remindLaterText, remindIntent) + } + } + + return notificationBuilder + } + + @Throws(NotificationConstructionFailedException::class) + internal fun fallbackToBasicNotification( + context: Context, + trackerActivityClass: Class?, + broadcastReceiverClass: Class?, + data: NotificationData + ): NotificationCompat.Builder { + val basicPushTemplate = BasicPushTemplate(data) + return construct( + context, + basicPushTemplate, + trackerActivityClass, + broadcastReceiverClass + ) + } + + /** + * Creates a pending intent for remind later button in a notification. + * + * @param context the application [Context] + * @param broadcastReceiverClass the [Class] of the broadcast receiver to set in the created pending intent + * @param channelId [String] containing the notification channel ID + * @param pushTemplate the [BasicPushTemplate] object containing the basic push template data + * @return the created remind later [PendingIntent] + */ + @VisibleForTesting + internal fun createRemindPendingIntent( + context: Context, + broadcastReceiverClass: Class?, + channelId: String, + pushTemplate: BasicPushTemplate + ): PendingIntent? { + if (broadcastReceiverClass == null) { + return null + } + Log.trace( + LOG_TAG, + SELF_TAG, + "Creating a remind later pending intent from a push template object." + ) + + val remindIntent = AEPPushNotificationBuilder.createIntent( + PushTemplateConstants.IntentActions.REMIND_LATER_CLICKED, + pushTemplate + ) + remindIntent.putExtra(PushPayloadKeys.CHANNEL_ID, channelId) + + broadcastReceiverClass.let { + remindIntent.setClass(context.applicationContext, broadcastReceiverClass) + } + + return PendingIntent.getBroadcast( + context, + 0, + remindIntent, + PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + } +} diff --git a/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/builders/InputBoxNotificationBuilder.kt b/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/builders/InputBoxNotificationBuilder.kt new file mode 100644 index 00000000..97a4d595 --- /dev/null +++ b/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/builders/InputBoxNotificationBuilder.kt @@ -0,0 +1,174 @@ +/* + Copyright 2024 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. +*/ + +package com.adobe.marketing.mobile.notificationbuilder.internal.builders + +import android.app.Activity +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.widget.RemoteViews +import androidx.core.app.NotificationCompat +import androidx.core.app.RemoteInput +import com.adobe.marketing.mobile.notificationbuilder.NotificationConstructionFailedException +import com.adobe.marketing.mobile.notificationbuilder.PushTemplateConstants +import com.adobe.marketing.mobile.notificationbuilder.PushTemplateConstants.LOG_TAG +import com.adobe.marketing.mobile.notificationbuilder.PushTemplateConstants.PushPayloadKeys +import com.adobe.marketing.mobile.notificationbuilder.R +import com.adobe.marketing.mobile.notificationbuilder.internal.extensions.createNotificationChannelIfRequired +import com.adobe.marketing.mobile.notificationbuilder.internal.extensions.setRemoteImage +import com.adobe.marketing.mobile.notificationbuilder.internal.templates.InputBoxPushTemplate +import com.adobe.marketing.mobile.services.Log +import java.util.Random + +/** + * Object responsible for constructing a [NotificationCompat.Builder] object containing an input box push template notification. + */ +internal object InputBoxNotificationBuilder { + private const val SELF_TAG = "InputBoxNotificationBuilder" + + @Throws(NotificationConstructionFailedException::class) + fun construct( + context: Context, + pushTemplate: InputBoxPushTemplate, + trackerActivityClass: Class?, + broadcastReceiverClass: Class? + ): NotificationCompat.Builder { + Log.trace(LOG_TAG, SELF_TAG, "Building an input box template push notification.") + val packageName = context.packageName + val smallLayout = RemoteViews(packageName, R.layout.push_template_collapsed) + val expandedLayout = RemoteViews(packageName, R.layout.push_template_expanded) + + val notificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val channelIdToUse: String = + notificationManager.createNotificationChannelIfRequired(context, pushTemplate) + + // create the notification builder with the common settings applied + val notificationBuilder = AEPPushNotificationBuilder.construct( + context, + pushTemplate, + channelIdToUse, + trackerActivityClass, + smallLayout, + expandedLayout, + R.id.basic_expanded_layout + ) + + // get push payload data. if we are handling an intent then we know that we should be building a feedback received notification. + val imageUri = + if (pushTemplate.isFromIntent) pushTemplate.feedbackImage else pushTemplate.imageUrl + expandedLayout.setRemoteImage(imageUri, R.id.expanded_template_image) + + val expandedBodyText = + if (pushTemplate.isFromIntent) pushTemplate.feedbackText else pushTemplate.expandedBodyText + val collapsedBodyText = + if (pushTemplate.isFromIntent) pushTemplate.feedbackText else pushTemplate.body + smallLayout.setTextViewText(R.id.notification_title, pushTemplate.title) + smallLayout.setTextViewText(R.id.notification_body, collapsedBodyText) + expandedLayout.setTextViewText(R.id.notification_title, pushTemplate.title) + expandedLayout.setTextViewText( + R.id.notification_body_expanded, expandedBodyText + ) + + // add an input box to capture user feedback if the push template is not from an intent + // otherwise, we are done building the notification + if (pushTemplate.isFromIntent) { + return notificationBuilder + } + Log.trace( + LOG_TAG, SELF_TAG, + "Adding an input box to capture text input. The input box receiver name is ${pushTemplate.inputBoxReceiverName}." + ) + addInputTextAction( + context, + trackerActivityClass, + notificationBuilder, + channelIdToUse, + pushTemplate + ) + + return notificationBuilder + } + + /** + * Adds an input text action for the notification. + * + * @param context the application [Context] + * @param trackerActivityClass the [Activity] class to launch when the input text is submitted + * @param builder the [NotificationCompat.Builder] to attach the action buttons + * @param channelId the [String] containing the channel ID to use for the notification + * @param pushTemplate the [InputBoxPushTemplate] object containing the input box push template data + * button is pressed + */ + private fun addInputTextAction( + context: Context, + trackerActivityClass: Class?, + builder: NotificationCompat.Builder, + channelId: String, + pushTemplate: InputBoxPushTemplate + ) { + val inputHint = + if (pushTemplate.inputTextHint.isNullOrEmpty()) PushTemplateConstants.DefaultValues.INPUT_BOX_DEFAULT_REPLY_TEXT else pushTemplate.inputTextHint + val remoteInput = RemoteInput.Builder(pushTemplate.inputBoxReceiverName) + .setLabel(inputHint) + .build() + + val replyPendingIntent = createInputReceivedPendingIntent( + context, + trackerActivityClass, + channelId, + pushTemplate + ) + + val action = + NotificationCompat.Action.Builder(null, inputHint, replyPendingIntent) + .addRemoteInput(remoteInput) + .build() + + builder.addAction(action) + } + + /** + * Creates a pending intent which resolves to the [trackerActivityClass] for the input submit action. + * + * @param context the application [Context] + * @param trackerActivityClass the [Activity] class to launch when the input text is submitted + * @param channelId the [String] containing the channel ID to use for the notification + * @param pushTemplate the [InputBoxPushTemplate] object containing the input box push template data + * @return the created [PendingIntent] + */ + private fun createInputReceivedPendingIntent( + context: Context, + trackerActivityClass: Class?, + channelId: String, + pushTemplate: InputBoxPushTemplate + ): PendingIntent { + val inputReceivedIntentExtras = pushTemplate.data.getBundle() + inputReceivedIntentExtras.putString(PushPayloadKeys.CHANNEL_ID, channelId) + val intent = Intent(PushTemplateConstants.NotificationAction.INPUT_RECEIVED) + trackerActivityClass?.let { + intent.setClass(context.applicationContext, trackerActivityClass) + } + intent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP) + intent.putExtras(inputReceivedIntentExtras) + + // Remote input requires a pending intent to be created with the FLAG_MUTABLE flag + return PendingIntent.getActivity( + context, + Random().nextInt(), + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE + ) + } +} diff --git a/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/builders/LegacyNotificationBuilder.kt b/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/builders/LegacyNotificationBuilder.kt new file mode 100644 index 00000000..e4144089 --- /dev/null +++ b/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/builders/LegacyNotificationBuilder.kt @@ -0,0 +1,94 @@ +/* + Copyright 2024 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. +*/ + +package com.adobe.marketing.mobile.notificationbuilder.internal.builders + +import android.app.Activity +import android.app.NotificationManager +import android.content.Context +import android.os.Build +import androidx.core.app.NotificationCompat +import com.adobe.marketing.mobile.notificationbuilder.PushTemplateConstants.LOG_TAG +import com.adobe.marketing.mobile.notificationbuilder.internal.extensions.addActionButtons +import com.adobe.marketing.mobile.notificationbuilder.internal.extensions.createNotificationChannelIfRequired +import com.adobe.marketing.mobile.notificationbuilder.internal.extensions.setLargeIcon +import com.adobe.marketing.mobile.notificationbuilder.internal.extensions.setNotificationClickAction +import com.adobe.marketing.mobile.notificationbuilder.internal.extensions.setNotificationDeleteAction +import com.adobe.marketing.mobile.notificationbuilder.internal.extensions.setSmallIcon +import com.adobe.marketing.mobile.notificationbuilder.internal.extensions.setSound +import com.adobe.marketing.mobile.notificationbuilder.internal.templates.BasicPushTemplate +import com.adobe.marketing.mobile.services.Log + +/** + * Object responsible for constructing a legacy push notification. + */ +internal object LegacyNotificationBuilder { + private const val SELF_TAG = "LegacyNotificationBuilder" + + fun construct( + context: Context, + pushTemplate: BasicPushTemplate, + trackerActivityClass: Class? + ): NotificationCompat.Builder { + Log.trace(LOG_TAG, SELF_TAG, "Building a legacy style push notification.") + + // create the notification channel + val notificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val channelId = + notificationManager.createNotificationChannelIfRequired(context, pushTemplate) + + // Create the notification builder object and set the ticker, title, body, and badge count + val builder = NotificationCompat.Builder(context, channelId) + .setTicker(pushTemplate.ticker) + .setContentTitle(pushTemplate.title) + .setContentText(pushTemplate.body) + .setNumber(pushTemplate.badgeCount) + // set a large icon if one is present + .setLargeIcon(pushTemplate.imageUrl, pushTemplate.title, pushTemplate.expandedBodyText) + // small Icon must be present, otherwise the notification will not be displayed. + .setSmallIcon(context, pushTemplate.smallIcon, pushTemplate.smallIconColor) + // set notification visibility + .setVisibility(pushTemplate.visibility.value) + // add any action buttons defined for the notification + .addActionButtons( + context, + trackerActivityClass, + pushTemplate.actionButtonsList, + pushTemplate.data.getBundle() + ) + // set custom sound, note this applies to API 25 and lower only as API 26 and up set the + // sound on the notification channel + .setSound(context, pushTemplate.sound) + // assign a click action pending intent to the notification + .setNotificationClickAction( + context, + trackerActivityClass, + pushTemplate.actionUri, + pushTemplate.data.getBundle() + ) + // set notification delete action + .setNotificationDeleteAction( + context, + trackerActivityClass + ) + + // if API level is below 26 (prior to notification channels) then notification priority is + // set on the notification builder + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + builder.setPriority(NotificationCompat.PRIORITY_HIGH) + .setVibrate(LongArray(0)) // hack to enable heads up notifications as a HUD style + // notification requires a tone or vibration + } + + return builder + } +} diff --git a/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/builders/ManualCarouselNotificationBuilder.kt b/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/builders/ManualCarouselNotificationBuilder.kt new file mode 100644 index 00000000..f0ab8e0b --- /dev/null +++ b/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/builders/ManualCarouselNotificationBuilder.kt @@ -0,0 +1,473 @@ +/* + Copyright 2024 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. +*/ + +package com.adobe.marketing.mobile.notificationbuilder.internal.builders + +import android.app.Activity +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.widget.RemoteViews +import androidx.annotation.VisibleForTesting +import androidx.core.app.NotificationCompat +import com.adobe.marketing.mobile.notificationbuilder.NotificationConstructionFailedException +import com.adobe.marketing.mobile.notificationbuilder.PushTemplateConstants +import com.adobe.marketing.mobile.notificationbuilder.PushTemplateConstants.LOG_TAG +import com.adobe.marketing.mobile.notificationbuilder.PushTemplateConstants.PushPayloadKeys +import com.adobe.marketing.mobile.notificationbuilder.R +import com.adobe.marketing.mobile.notificationbuilder.internal.PushTemplateImageUtils +import com.adobe.marketing.mobile.notificationbuilder.internal.extensions.createNotificationChannelIfRequired +import com.adobe.marketing.mobile.notificationbuilder.internal.extensions.setRemoteViewClickAction +import com.adobe.marketing.mobile.notificationbuilder.internal.templates.CarouselPushTemplate +import com.adobe.marketing.mobile.notificationbuilder.internal.templates.ManualCarouselPushTemplate +import com.adobe.marketing.mobile.services.Log + +/** + * Object responsible for constructing a [NotificationCompat.Builder] object containing a manual or filmstrip carousel push template notification. + */ +internal object ManualCarouselNotificationBuilder { + private const val SELF_TAG = "ManualCarouselNotificationBuilder" + + @Throws(NotificationConstructionFailedException::class) + fun construct( + context: Context, + pushTemplate: ManualCarouselPushTemplate, + trackerActivityClass: Class?, + broadcastReceiverClass: Class? + ): NotificationCompat.Builder { + Log.trace(LOG_TAG, SELF_TAG, "Building a manual carousel template push notification.") + + // download carousel images + val downloadedImagesCount = PushTemplateImageUtils.cacheImages( + pushTemplate.carouselItems.map { it.imageUri } + ) + + // fallback to a basic push template notification builder if less than 3 images were able + // to be downloaded + if (downloadedImagesCount < PushTemplateConstants.DefaultValues.CAROUSEL_MINIMUM_IMAGE_COUNT) { + Log.warning(LOG_TAG, SELF_TAG, "Less than 3 images are available for the manual carousel push template, falling back to a basic push template.") + return BasicNotificationBuilder.fallbackToBasicNotification( + context, + trackerActivityClass, + broadcastReceiverClass, + pushTemplate.data + ) + } + + // set the expanded layout depending on the carousel type + val packageName = context.packageName + val smallLayout = RemoteViews(packageName, R.layout.push_template_collapsed) + val expandedLayout = + if (pushTemplate.carouselLayout == PushTemplateConstants.DefaultValues.FILMSTRIP_CAROUSEL_MODE) + RemoteViews( + packageName, + R.layout.push_template_filmstrip_carousel + ) else RemoteViews(packageName, R.layout.push_template_manual_carousel) + + val validCarouselItems = downloadCarouselItems(pushTemplate.carouselItems) + + // get the indices for the carousel + val carouselIndices = getCarouselIndices(pushTemplate, validCarouselItems.size) + + // store the updated center image index + pushTemplate.centerImageIndex = carouselIndices.second + + // populate the images for the manual carousel + setupCarouselImages( + context, + carouselIndices, + pushTemplate, + trackerActivityClass, + expandedLayout, + validCarouselItems, + packageName, + ) + + val notificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + // create the notification channel if needed + val channelIdToUse = + notificationManager.createNotificationChannelIfRequired(context, pushTemplate) + + // create the notification builder with the common settings applied + val notificationBuilder = AEPPushNotificationBuilder.construct( + context, + pushTemplate, + channelIdToUse, + trackerActivityClass, + smallLayout, + expandedLayout, + R.id.carousel_container_layout + ) + + // handle left and right navigation buttons + setupNavigationButtons( + context, + pushTemplate, + broadcastReceiverClass, + expandedLayout, + channelIdToUse + ) + + return notificationBuilder + } + + /** + * Downloads the images for a carousel push template. + * + * @param items the list of [CarouselPushTemplate.CarouselItem] objects to be displayed in the filmstrip carousel + * @return a list of `CarouselPushTemplate.CarouselItem` objects that were successfully downloaded + */ + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun downloadCarouselItems( + items: List + ): List { + val validCarouselItems = mutableListOf() + for (item: CarouselPushTemplate.CarouselItem in items) { + val imageUri: String = item.imageUri + val pushImage: Bitmap? = PushTemplateImageUtils.getCachedImage(imageUri) + if (pushImage == null) { + Log.warning( + LOG_TAG, + SELF_TAG, + "Failed to retrieve an image from $imageUri, will not create a new carousel item." + ) + continue + } + validCarouselItems.add(item) + } + return validCarouselItems + } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun getCarouselIndices( + pushTemplate: ManualCarouselPushTemplate, + carouselSize: Int + ): Triple { + val carouselIndices: Triple + if (pushTemplate.intentAction?.isNotEmpty() == true) { + carouselIndices = + if (pushTemplate.intentAction == PushTemplateConstants.IntentActions.MANUAL_CAROUSEL_LEFT_CLICKED || pushTemplate.intentAction == PushTemplateConstants.IntentActions.FILMSTRIP_LEFT_CLICKED) { + getNewIndicesForNavigateLeft(pushTemplate.centerImageIndex, carouselSize) + } else { + getNewIndicesForNavigateRight(pushTemplate.centerImageIndex, carouselSize) + } + } else { // setup default indices if not building the notification from an intent + carouselIndices = + if (pushTemplate.carouselLayout == PushTemplateConstants.DefaultValues.FILMSTRIP_CAROUSEL_MODE) { + Triple( + PushTemplateConstants.DefaultValues.FILMSTRIP_CAROUSEL_CENTER_INDEX - 1, + PushTemplateConstants.DefaultValues.FILMSTRIP_CAROUSEL_CENTER_INDEX, + PushTemplateConstants.DefaultValues.FILMSTRIP_CAROUSEL_CENTER_INDEX + 1 + ) + } else { + Triple( + carouselSize - 1, + PushTemplateConstants.DefaultValues.MANUAL_CAROUSEL_START_INDEX, + PushTemplateConstants.DefaultValues.MANUAL_CAROUSEL_START_INDEX + 1 + ) + } + } + + return carouselIndices + } + + private fun setupCarouselImages( + context: Context, + newIndices: Triple, + pushTemplate: ManualCarouselPushTemplate, + trackerActivityClass: Class?, + expandedLayout: RemoteViews, + validCarouselItems: List, + packageName: String?, + ) { + if (pushTemplate.carouselLayout == PushTemplateConstants.DefaultValues.FILMSTRIP_CAROUSEL_MODE) { + populateFilmstripCarouselImages( + context, + validCarouselItems, + newIndices, + pushTemplate, + trackerActivityClass, + expandedLayout + ) + } else { + populateManualCarouselImages( + context, + validCarouselItems, + packageName, + newIndices.second, + pushTemplate, + trackerActivityClass, + expandedLayout + ) + } + } + + private fun setupNavigationButtons( + context: Context, + pushTemplate: ManualCarouselPushTemplate, + broadcastReceiverClass: Class?, + expandedLayout: RemoteViews, + channelId: String + ) { + val clickPair = + if (pushTemplate.carouselLayout == PushTemplateConstants.DefaultValues.DEFAULT_MANUAL_CAROUSEL_MODE) { + Pair( + PushTemplateConstants.IntentActions.MANUAL_CAROUSEL_LEFT_CLICKED, + PushTemplateConstants.IntentActions.MANUAL_CAROUSEL_RIGHT_CLICKED + ) + } else { + Pair( + PushTemplateConstants.IntentActions.FILMSTRIP_LEFT_CLICKED, + PushTemplateConstants.IntentActions.FILMSTRIP_RIGHT_CLICKED + ) + } + + val pendingIntentLeftButton = createCarouselNavigationClickPendingIntent( + context, + pushTemplate, + clickPair.first, + broadcastReceiverClass, + channelId + ) + + val pendingIntentRightButton = createCarouselNavigationClickPendingIntent( + context, + pushTemplate, + clickPair.second, + broadcastReceiverClass, + channelId + ) + + expandedLayout.setOnClickPendingIntent(R.id.leftImageButton, pendingIntentLeftButton) + expandedLayout.setOnClickPendingIntent(R.id.rightImageButton, pendingIntentRightButton) + } + + /** + * Populates the images for a manual carousel push template. + * + * @param context the current [Context] of the application + * @param items the list of [CarouselPushTemplate.CarouselItem] objects to be displayed in the carousel + * @param packageName the [String] containing the package name of the application + * @param centerIndex the `Int` index of the center image in the carousel + * @param pushTemplate the [ManualCarouselPushTemplate] object containing the push template data + * @param trackerActivityClass the [Class] of the activity that will be used for tracking interactions with the carousel item + * @param expandedLayout the [RemoteViews] containing the expanded layout of the notification + */ + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun populateManualCarouselImages( + context: Context, + items: List, + packageName: String?, + centerIndex: Int, + pushTemplate: ManualCarouselPushTemplate, + trackerActivityClass: Class?, + expandedLayout: RemoteViews + ) { + for (item: CarouselPushTemplate.CarouselItem in items) { + val imageUri = item.imageUri + val pushImage: Bitmap? = PushTemplateImageUtils.getCachedImage(imageUri) + if (pushImage == null) { + Log.warning( + LOG_TAG, + SELF_TAG, + "Failed to retrieve an image from $imageUri, will not create a new carousel item." + ) + continue + } + val carouselItemRemoteView = + RemoteViews(packageName, R.layout.push_template_carousel_item) + carouselItemRemoteView.setImageViewBitmap(R.id.carousel_item_image_view, pushImage) + carouselItemRemoteView.setTextViewText(R.id.carousel_item_caption, item.captionText) + + // assign a click action pending intent for each carousel item + val interactionUri = + if (item.interactionUri.isNullOrEmpty()) pushTemplate.actionUri else item.interactionUri + interactionUri?.let { + carouselItemRemoteView.setRemoteViewClickAction( + context, + trackerActivityClass, + R.id.carousel_item_image_view, + interactionUri, + null, + pushTemplate.data.getBundle() + ) + } + + // add the carousel item to the view flipper + expandedLayout.addView(R.id.manual_carousel_view_flipper, carouselItemRemoteView) + + // set the center image + expandedLayout.setDisplayedChild( + R.id.manual_carousel_view_flipper, + centerIndex + ) + } + } + + /** + * Populates the images for a manual filmstrip carousel push template. + * + * @param context the current [Context] of the application + * @param imageCaptions the list of [String] captions for each filmstrip carousel image + * @param imageClickActions the list of [String] click actions for each filmstrip carousel image + * @param newIndices a [Triple] of [Int] indices for the new left, center, and right images + * @param pushTemplate the [ManualCarouselPushTemplate] object containing the push template data + * @param trackerActivityClass the [Class] of the activity that will be used for tracking interactions with the carousel item + * @param expandedLayout the [RemoteViews] containing the expanded layout of the notification + */ + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun populateFilmstripCarouselImages( + context: Context, + validCarouselItems: List, + newIndices: Triple, + pushTemplate: ManualCarouselPushTemplate, + trackerActivityClass: Class?, + expandedLayout: RemoteViews + ) { + // get all captions present then set center caption text + val centerCaptionText = validCarouselItems[newIndices.second].captionText + expandedLayout.setTextViewText( + R.id.manual_carousel_filmstrip_caption, + centerCaptionText + ) + + // set the downloaded bitmaps in the filmstrip image views + val assetCacheLocation = PushTemplateImageUtils.getAssetCacheLocation() + if (assetCacheLocation.isNullOrEmpty()) { + Log.warning( + LOG_TAG, + SELF_TAG, + "Asset cache location is null or empty, unable to retrieve filmstrip carousel images." + ) + return + } + + val newLeftImage = PushTemplateImageUtils.getCachedImage( + validCarouselItems[newIndices.first].imageUri + ) + expandedLayout.setImageViewBitmap( + R.id.manual_carousel_filmstrip_left, newLeftImage + ) + + val newCenterImage = PushTemplateImageUtils.getCachedImage( + validCarouselItems[newIndices.second].imageUri + ) + expandedLayout.setImageViewBitmap( + R.id.manual_carousel_filmstrip_center, newCenterImage + ) + + val newRightImage = PushTemplateImageUtils.getCachedImage( + validCarouselItems[newIndices.third].imageUri + ) + expandedLayout.setImageViewBitmap( + R.id.manual_carousel_filmstrip_right, newRightImage + ) + + // assign a click action pending intent to the center image view + val interactionUri = + if (!validCarouselItems[newIndices.second].interactionUri.isNullOrEmpty()) validCarouselItems[newIndices.second].interactionUri + else pushTemplate.actionUri + expandedLayout.setRemoteViewClickAction( + context, + trackerActivityClass, + R.id.manual_carousel_filmstrip_center, + interactionUri, + null, + pushTemplate.data.getBundle() + ) + } + + /** + * Calculates a new left, center, and right index for a carousel skip left press given the current center index and total number + * of images + * + * @param centerIndex [Int] containing the current center image index + * @param listSize `Int` containing the total number of images + * @return [Triple] containing the calculated left, center, and right indices + */ + private fun getNewIndicesForNavigateLeft( + centerIndex: Int, + listSize: Int + ): Triple { + val newCenterIndex = (centerIndex - 1 + listSize) % listSize + val newLeftIndex = (newCenterIndex - 1 + listSize) % listSize + Log.trace( + LOG_TAG, SELF_TAG, + "Calculated new indices. New center index is $newCenterIndex, new left index is $newLeftIndex, and new right index is $centerIndex." + ) + return Triple(newLeftIndex, newCenterIndex, centerIndex) + } + + /** + * Calculates a new left, center, and right index for a carousel skip right press given the current center index and total number + * of images + * + * @param centerIndex [Int] containing the current center image index + * @param listSize `Int` containing the total number of images + * @return [Triple] containing the calculated left, center, and right indices + */ + private fun getNewIndicesForNavigateRight( + centerIndex: Int, + listSize: Int + ): Triple { + val newCenterIndex = (centerIndex + 1) % listSize + val newRightIndex = (newCenterIndex + 1) % listSize + Log.trace( + LOG_TAG, + SELF_TAG, + "Calculated new indices. New center index is $newCenterIndex, new left index is $centerIndex, and new right index is $newRightIndex." + ) + return Triple(centerIndex, newCenterIndex, newRightIndex) + } + + /** + * Creates a click intent for the specified [Intent] action. This intent is used to handle interactions + * with the skip left and skip right buttons in a filmstrip or manual carousel push template notification. + * + * @param context the application [Context] + * @param pushTemplate the [ManualCarouselPushTemplate] object containing the manual carousel push template data + * @param intentAction [String] containing the intent action + * @param broadcastReceiverClass the [Class] of the broadcast receiver to set in the created pending intent + * @param channelId [String] containing the notification channel ID + * @return the created click [Intent] + */ + private fun createCarouselNavigationClickPendingIntent( + context: Context, + pushTemplate: ManualCarouselPushTemplate, + intentAction: String, + broadcastReceiverClass: Class?, + channelId: String + ): PendingIntent? { + if (broadcastReceiverClass == null) { + return null + } + val clickIntent = AEPPushNotificationBuilder.createIntent(intentAction, pushTemplate) + clickIntent.putExtra(PushPayloadKeys.CHANNEL_ID, channelId) + clickIntent.putExtra( + PushTemplateConstants.IntentKeys.CENTER_IMAGE_INDEX, + pushTemplate.centerImageIndex.toString() + ) + broadcastReceiverClass.let { + clickIntent.setClass(context, broadcastReceiverClass) + } + return PendingIntent.getBroadcast( + context, + 0, + clickIntent, + PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + } +} diff --git a/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/builders/MultiIconNotificationBuilder.kt b/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/builders/MultiIconNotificationBuilder.kt new file mode 100644 index 00000000..d2e97946 --- /dev/null +++ b/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/builders/MultiIconNotificationBuilder.kt @@ -0,0 +1,140 @@ +/* + Copyright 2024 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. +*/ + +package com.adobe.marketing.mobile.notificationbuilder.internal.builders + +import android.app.Activity +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.widget.RemoteViews +import androidx.core.app.NotificationCompat +import com.adobe.marketing.mobile.notificationbuilder.NotificationConstructionFailedException +import com.adobe.marketing.mobile.notificationbuilder.PushTemplateConstants +import com.adobe.marketing.mobile.notificationbuilder.R +import com.adobe.marketing.mobile.notificationbuilder.internal.extensions.createNotificationChannelIfRequired +import com.adobe.marketing.mobile.notificationbuilder.internal.extensions.setRemoteViewClickAction +import com.adobe.marketing.mobile.notificationbuilder.internal.extensions.setRemoteViewImage +import com.adobe.marketing.mobile.notificationbuilder.internal.templates.MultiIconPushTemplate +import com.adobe.marketing.mobile.services.Log +import java.util.Random + +internal object MultiIconNotificationBuilder { + const val SELF_TAG = "MultiIconNotificationBuilder" + + fun construct( + context: Context, + pushTemplate: MultiIconPushTemplate, + trackerActivityClass: Class? + ): NotificationCompat.Builder { + + Log.trace( + PushTemplateConstants.LOG_TAG, + SELF_TAG, + "Building an icon template push notification." + ) + + val packageName = context.packageName + val notificationLayout = RemoteViews(packageName, R.layout.push_template_multi_icon) + + val notificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val channelIdToUse: String = notificationManager.createNotificationChannelIfRequired( + context, + pushTemplate + ) + + populateIconsForMultiIconTemplate( + context, + trackerActivityClass, + notificationLayout, + pushTemplate, + pushTemplate.templateItemList, + packageName + ) + + setCancelIcon( + notificationLayout, + pushTemplate + ) + + val closeButtonIntentExtra = Bundle(pushTemplate.data.getBundle()) // copy the bundle + closeButtonIntentExtra.putString(PushTemplateConstants.PushPayloadKeys.STICKY, "false") + val dismissIntent = Intent(PushTemplateConstants.NotificationAction.DISMISSED) + trackerActivityClass?.let { + dismissIntent.setClass(context.applicationContext, trackerActivityClass) + } + dismissIntent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) + val pendingIntent = PendingIntent.getActivity( + context, + Random().nextInt(), + dismissIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + notificationLayout.setOnClickPendingIntent( + R.id.five_icon_close_button, + pendingIntent + ) + + return AEPPushNotificationBuilder.construct( + context, + pushTemplate, + channelIdToUse, + trackerActivityClass, + notificationLayout, + notificationLayout, + R.id.carousel_container_layout + ) + } + + private fun setCancelIcon( + notificationLayout: RemoteViews, + pushTemplate: MultiIconPushTemplate, + ) { + val iconString = pushTemplate.cancelIcon + notificationLayout.setRemoteViewImage(iconString, R.id.five_icon_close_button) + } + + private fun populateIconsForMultiIconTemplate( + context: Context, + trackerActivityClass: Class?, + notificationLayout: RemoteViews, + pushTemplate: MultiIconPushTemplate, + items: MutableList, + packageName: String? + ) { + var validImagesAddedCount = 0 + for (item in items) { + val iconItem = RemoteViews(packageName, R.layout.multi_icon_template_item) + if (iconItem.setRemoteViewImage(item.iconUrl, R.id.icon_item_image_view)) { + validImagesAddedCount++ + } + + trackerActivityClass?.let { + val interactionUri = item.actionUri ?: pushTemplate.actionUri + iconItem.setRemoteViewClickAction( + context, + trackerActivityClass, + R.id.icon_item_image_view, + interactionUri, + null, + pushTemplate.data.getBundle() + ) + } + notificationLayout.addView(R.id.icons_layout_linear, iconItem) + } + if (validImagesAddedCount < PushTemplateConstants.DefaultValues.ICON_TEMPLATE_MIN_IMAGE_COUNT) { + throw NotificationConstructionFailedException("Valid icons are less then 3, cannot build a notification.") + } + } +} diff --git a/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/builders/ProductCatalogNotificationBuilder.kt b/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/builders/ProductCatalogNotificationBuilder.kt new file mode 100644 index 00000000..b03eebdc --- /dev/null +++ b/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/builders/ProductCatalogNotificationBuilder.kt @@ -0,0 +1,338 @@ +/* + Copyright 2024 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. +*/ + +package com.adobe.marketing.mobile.notificationbuilder.internal.builders + +import android.app.Activity +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.widget.RemoteViews +import androidx.core.app.NotificationCompat +import com.adobe.marketing.mobile.notificationbuilder.NotificationConstructionFailedException +import com.adobe.marketing.mobile.notificationbuilder.PushTemplateConstants +import com.adobe.marketing.mobile.notificationbuilder.PushTemplateConstants.LOG_TAG +import com.adobe.marketing.mobile.notificationbuilder.R +import com.adobe.marketing.mobile.notificationbuilder.internal.PendingIntentUtils +import com.adobe.marketing.mobile.notificationbuilder.internal.PushTemplateImageUtils +import com.adobe.marketing.mobile.notificationbuilder.internal.extensions.createNotificationChannelIfRequired +import com.adobe.marketing.mobile.notificationbuilder.internal.extensions.setElementColor +import com.adobe.marketing.mobile.notificationbuilder.internal.templates.ProductCatalogPushTemplate +import com.adobe.marketing.mobile.services.Log + +/** + * Object responsible for constructing a [NotificationCompat.Builder] object containing a product catalog template notification. + */ +internal object ProductCatalogNotificationBuilder { + private const val SELF_TAG = "ProductCatalogNotificationBuilder" + private var downloadedImageCount: Int = 0 + + @Throws(NotificationConstructionFailedException::class) + fun construct( + context: Context, + pushTemplate: ProductCatalogPushTemplate, + trackerActivityClass: Class?, + broadcastReceiverClass: Class? + ): NotificationCompat.Builder { + Log.trace( + LOG_TAG, + SELF_TAG, + "Building a product catalog push notification." + ) + + // fast fail if we can't download a catalog item image + val catalogImageUris = pushTemplate.catalogItems.map { it.img } + downloadedImageCount = PushTemplateImageUtils.cacheImages(catalogImageUris) + if (downloadedImageCount != catalogImageUris.size) { + Log.error( + LOG_TAG, + SELF_TAG, + "Failed to download all images for the product catalog notification." + ) + throw NotificationConstructionFailedException("Failed to download all images for the product catalog notification.") + } + + val packageName = context.packageName + val smallLayout = RemoteViews(packageName, R.layout.push_template_collapsed) + val expandedLayout = + if (pushTemplate.displayLayout == PushTemplateConstants.DefaultValues.PRODUCT_CATALOG_VERTICAL_LAYOUT) { + RemoteViews(packageName, R.layout.push_tempate_vertical_catalog) + } else { + RemoteViews(packageName, R.layout.push_template_horizontal_catalog) + } + + val notificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val channelIdToUse: String = + notificationManager.createNotificationChannelIfRequired(context, pushTemplate) + + // create the notification builder with the common settings applied + val notificationBuilder = AEPPushNotificationBuilder.construct( + context, + pushTemplate, + channelIdToUse, + trackerActivityClass, + smallLayout, + expandedLayout, + R.id.catalog_container_layout + ) + + val catalogItems = pushTemplate.catalogItems + // set the currently selected product within the main product catalog image view + populateCenterImage( + context, + trackerActivityClass, + expandedLayout, + pushTemplate + ) + + // set the product title, description, and price + expandedLayout.setTextViewText( + R.id.product_title, + catalogItems[pushTemplate.currentIndex].title + ) + expandedLayout.setTextViewText( + R.id.product_description, + catalogItems[pushTemplate.currentIndex].body + ) + expandedLayout.setTextViewText( + R.id.product_price, + catalogItems[pushTemplate.currentIndex].price + ) + + // setup the CTA button + setupCtaButton(context, trackerActivityClass, expandedLayout, pushTemplate) + + // populate product thumbnails + populateThumbnails( + context, + broadcastReceiverClass, + channelIdToUse, + expandedLayout, + catalogItems, + pushTemplate + ) + + return notificationBuilder + } + + /** + * Sets the product catalog center image and its click action. + * + * @param context the application [Context] + * @param trackerActivityClass the [Class] of the activity to set in the created pending intent for tracking purposes + * @param expandedLayout the [RemoteViews] object containing the expanded layout of the push template notification + * @param pushTemplate the [ProductCatalogPushTemplate] object containing the product catalog push template data + */ + private fun populateCenterImage( + context: Context, + trackerActivityClass: Class?, + expandedLayout: RemoteViews, + pushTemplate: ProductCatalogPushTemplate + ) { + Log.trace( + LOG_TAG, + SELF_TAG, + "Populating center image for product catalog notification." + ) + + val pushImage = + PushTemplateImageUtils.getCachedImage(pushTemplate.catalogItems[pushTemplate.currentIndex].img) + expandedLayout.setImageViewBitmap(R.id.product_image, pushImage) + expandedLayout.setOnClickPendingIntent( + R.id.product_image, + PendingIntentUtils.createPendingIntentForTrackerActivity( + context, + trackerActivityClass, + pushTemplate.catalogItems[pushTemplate.currentIndex].uri, + PushTemplateConstants.CatalogActionIds.PRODUCT_IMAGE_CLICKED, + pushTemplate.data.getBundle() + ) + ) + } + + /** + * Sets the product catalog thumbnails and their click actions. + * + * @param context the application [Context] + * @param broadcastReceiverClass the [Class] of the broadcast receiver to set in the created pending intent + * @param channelIdToUse [String] containing the notification channel ID + * @param expandedLayout the [RemoteViews] object containing the expanded layout of the push template notification + * @param catalogItems the list of [ProductCatalogPushTemplate.CatalogItem] objects containing the product catalog items + * @param pushTemplate the [ProductCatalogPushTemplate] object containing the product catalog push template data + */ + private fun populateThumbnails( + context: Context, + broadcastReceiverClass: Class?, + channelIdToUse: String, + expandedLayout: RemoteViews, + catalogItems: List, + pushTemplate: ProductCatalogPushTemplate + ) { + Log.trace( + LOG_TAG, + SELF_TAG, + "Populating product catalog thumbnails." + ) + + val thumbIds = listOf( + R.id.product_thumbnail_1, + R.id.product_thumbnail_2, + R.id.product_thumbnail_3 + ) + for (index in catalogItems.indices) { + val thumbImage = PushTemplateImageUtils.getCachedImage(catalogItems[index].img) + if (thumbImage == null) { + Log.warning( + LOG_TAG, + SELF_TAG, + "No image found for catalog item thumbnail." + ) + throw NotificationConstructionFailedException("No image found for catalog item thumbnail.") + } else { + expandedLayout.setImageViewBitmap(thumbIds[index], thumbImage) + } + + // create a pending intent for the thumbnail + val pendingIntentThumbnailInteraction = createThumbnailInteractionPendingIntent( + context, + broadcastReceiverClass, + channelIdToUse, + pushTemplate, + index + ) + expandedLayout.setOnClickPendingIntent( + thumbIds[index], + pendingIntentThumbnailInteraction + ) + } + } + + /** + * Sets the CTA button for the product catalog notification. + * + * @param context the application [Context] + * @param trackerActivityClass the [Class] of the activity to set in the created pending intent for tracking purposes + * @param expandedLayout the [RemoteViews] object containing the expanded layout of the push template notification + * @param pushTemplate the [ProductCatalogPushTemplate] object containing the product catalog push template data + */ + private fun setupCtaButton( + context: Context, + trackerActivityClass: Class?, + expandedLayout: RemoteViews, + pushTemplate: ProductCatalogPushTemplate + ) { + // apply the color to the cta button + expandedLayout.setCtaButtonColor(R.id.cta_button, pushTemplate.ctaButtonColor) + + // apply text to the cta button + expandedLayout.setTextViewText(R.id.cta_button, pushTemplate.ctaButtonText) + + // apple the text color to the cta button + expandedLayout.setCtaButtonTextColor(R.id.cta_button, pushTemplate.ctaButtonTextColor) + + // apply the open uri action to the cta button + expandedLayout.setOnClickPendingIntent( + R.id.cta_button, + PendingIntentUtils.createPendingIntentForTrackerActivity( + context, + trackerActivityClass, + pushTemplate.ctaButtonUri, + PushTemplateConstants.CatalogActionIds.CTA_BUTTON_CLICKED, + pushTemplate.data.getBundle() + ) + ) + } + + /** + * Sets custom colors to the product catalog CTA button. + * + * @param containerViewId [Int] containing the resource id of the push template notification RemoteViews + * @param buttonColor [String] containing the hex color code for the cta button + */ + private fun RemoteViews.setCtaButtonColor( + containerViewId: Int, + buttonColor: String? + ) { + setElementColor( + containerViewId, + "#$buttonColor", + PushTemplateConstants.MethodNames.SET_BACKGROUND_COLOR, + PushTemplateConstants.FriendlyViewNames.CTA_BUTTON + ) + } + + /** + * Sets custom colors to the product catalog CTA button text. + * + * @param containerViewId [Int] containing the resource id of the push template notification RemoteViews + * @param buttonTextColor [String] containing the hex color code for the cta button text + */ + private fun RemoteViews.setCtaButtonTextColor( + containerViewId: Int, + buttonTextColor: String? + ) { + setElementColor( + containerViewId, + "#$buttonTextColor", + PushTemplateConstants.MethodNames.SET_TEXT_COLOR, + PushTemplateConstants.FriendlyViewNames.CTA_BUTTON + ) + } + + /** + * Creates a pending intent for a thumbnail interaction in a product catalog notification. + * + * @param context the application [Context] + * @param broadcastReceiverClass the [Class] of the broadcast receiver to set in the created pending intent + * @param channelId [String] containing the notification channel ID + * @param pushTemplate the [ProductCatalogPushTemplate] object containing the product catalog push template data + * @param currentIndex [Int] containing the index of the current product in the catalog + * @return the created thumbnail interaction [PendingIntent] + */ + private fun createThumbnailInteractionPendingIntent( + context: Context, + broadcastReceiverClass: Class?, + channelId: String, + pushTemplate: ProductCatalogPushTemplate, + currentIndex: Int + ): PendingIntent? { + if (broadcastReceiverClass == null) { + return null + } + + Log.trace( + LOG_TAG, + SELF_TAG, + "Creating a thumbnail interaction pending intent for thumbnail at index $currentIndex" + ) + + val thumbnailClickIntent = AEPPushNotificationBuilder.createIntent( + PushTemplateConstants.IntentActions.CATALOG_THUMBNAIL_CLICKED, + pushTemplate + ).apply { + setClass(context.applicationContext, broadcastReceiverClass) + putExtra(PushTemplateConstants.PushPayloadKeys.CHANNEL_ID, channelId) + putExtra( + PushTemplateConstants.IntentKeys.CATALOG_ITEM_INDEX, + currentIndex.toString() + ) + } + + return PendingIntent.getBroadcast( + context, + pushTemplate.tag.hashCode() + currentIndex, + thumbnailClickIntent, + PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + } +} diff --git a/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/builders/ProductRatingNotificationBuilder.kt b/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/builders/ProductRatingNotificationBuilder.kt new file mode 100644 index 00000000..ceff33da --- /dev/null +++ b/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/builders/ProductRatingNotificationBuilder.kt @@ -0,0 +1,213 @@ +/* + Copyright 2024 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. +*/ + +package com.adobe.marketing.mobile.notificationbuilder.internal.builders + +import android.app.Activity +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.os.Bundle +import android.view.View +import android.widget.RemoteViews +import androidx.core.app.NotificationCompat +import com.adobe.marketing.mobile.notificationbuilder.NotificationConstructionFailedException +import com.adobe.marketing.mobile.notificationbuilder.PushTemplateConstants +import com.adobe.marketing.mobile.notificationbuilder.PushTemplateConstants.LOG_TAG +import com.adobe.marketing.mobile.notificationbuilder.R +import com.adobe.marketing.mobile.notificationbuilder.internal.PushTemplateImageUtils +import com.adobe.marketing.mobile.notificationbuilder.internal.extensions.createNotificationChannelIfRequired +import com.adobe.marketing.mobile.notificationbuilder.internal.extensions.setNotificationTitleTextColor +import com.adobe.marketing.mobile.notificationbuilder.internal.extensions.setRemoteViewClickAction +import com.adobe.marketing.mobile.notificationbuilder.internal.extensions.setRemoteViewImage +import com.adobe.marketing.mobile.notificationbuilder.internal.templates.ProductRatingPushTemplate +import com.adobe.marketing.mobile.services.Log + +internal object ProductRatingNotificationBuilder { + private const val SELF_TAG = "ProductRatingNotificationBuilder" + + @Throws(NotificationConstructionFailedException::class) + fun construct( + context: Context, + pushTemplate: ProductRatingPushTemplate, + trackerActivityClass: Class?, + broadcastReceiverClass: Class? + ): NotificationCompat.Builder { + Log.trace( + LOG_TAG, + SELF_TAG, + "Building a rating template push notification." + ) + val packageName = context.packageName + val smallLayout = RemoteViews(packageName, R.layout.push_template_collapsed) + val expandedLayout = RemoteViews(packageName, R.layout.push_template_product_rating_expanded) + + val notificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val channelIdToUse: String = notificationManager.createNotificationChannelIfRequired( + context, + pushTemplate + ) + + // create the notification builder with the common settings applied + val notificationBuilder = AEPPushNotificationBuilder.construct( + context, + pushTemplate, + channelIdToUse, + trackerActivityClass, + smallLayout, + expandedLayout, + R.id.rating_expanded_layout + ) + + // set the image on the notification + val imageUri = pushTemplate.imageUrl + val downloadedImageCount = PushTemplateImageUtils.cacheImages(listOf(imageUri)) + + if (downloadedImageCount == 0) { + Log.trace( + LOG_TAG, + SELF_TAG, + "No image found for rating push template." + ) + expandedLayout.setViewVisibility(R.id.expanded_template_image, View.GONE) + } else { + expandedLayout.setImageViewBitmap( + R.id.expanded_template_image, + PushTemplateImageUtils.getCachedImage(imageUri) + ) + } + + // populate the rating icons + populateRatingIcons( + context, + broadcastReceiverClass, + expandedLayout, + pushTemplate, + packageName, + channelIdToUse + ) + + // check if confirm button needs to be shown + if (pushTemplate.ratingSelected > PushTemplateConstants.ProductRatingKeys.RATING_UNSELECTED) { + expandedLayout.setViewVisibility(R.id.rating_confirm, View.VISIBLE) + expandedLayout.setNotificationTitleTextColor( + pushTemplate.titleTextColor, + R.id.rating_confirm + ) + + // add pending intent for confirm click + // sticky is set to false as the notification will be dismissed after confirm click + val ratingConfirmedIntentExtras = Bundle(pushTemplate.data.getBundle()) // copy the bundle + ratingConfirmedIntentExtras.putString(PushTemplateConstants.PushPayloadKeys.STICKY, "false") + val selectedRatingAction = pushTemplate.ratingActionList[pushTemplate.ratingSelected] + expandedLayout.setRemoteViewClickAction( + context, + trackerActivityClass, + R.id.rating_confirm, + selectedRatingAction.link, + pushTemplate.ratingSelected.toString(), + ratingConfirmedIntentExtras + ) + } else { + // hide confirm if no rating is selected + expandedLayout.setViewVisibility(R.id.rating_confirm, View.INVISIBLE) + } + + return notificationBuilder + } + + /** + * Populates the rating icons in the notification. + * + * @param context the current [Context] of the application + * @param broadcastReceiverClass the [Class] of the broadcast receiver to set in the created pending intent + * @param expandedLayout the [RemoteViews] containing the expanded layout of the notification + * @param pushTemplate the [ProductRatingPushTemplate] object containing the product rating push template data + * @param packageName the `String` name of the application package used to locate the layout resources + * @param channelIdToUse the `String` containing the channel ID to use for the notification + */ + private fun populateRatingIcons( + context: Context, + broadcastReceiverClass: Class?, + expandedLayout: RemoteViews, + pushTemplate: ProductRatingPushTemplate, + packageName: String?, + channelIdToUse: String + ) { + // set the rating icons in the notification based on the rating selected + for (i in 0 until pushTemplate.ratingActionList.size) { + val ratingIconLayout = RemoteViews(packageName, R.layout.push_template_product_rating_icon_layout) + val ratingIconImageView = R.id.rating_icon_image + if (i <= pushTemplate.ratingSelected) { + if (!ratingIconLayout.setRemoteViewImage(pushTemplate.ratingSelectedIcon, ratingIconImageView)) { + throw NotificationConstructionFailedException("Image for selected rating icon is invalid.") + } + } else if (!ratingIconLayout.setRemoteViewImage(pushTemplate.ratingUnselectedIcon, ratingIconImageView)) { + throw NotificationConstructionFailedException("Image for unselected rating icon is invalid.") + } + expandedLayout.addView(R.id.rating_icons_container, ratingIconLayout) + + // add pending intent for rating icon click + val ratingButtonPendingIntent = createRatingButtonPendingIntent( + context, + broadcastReceiverClass, + channelIdToUse, + pushTemplate, + i + ) + ratingIconLayout.setOnClickPendingIntent(ratingIconImageView, ratingButtonPendingIntent) + } + } + + /** + * Creates a pending intent for rating icon click in a notification. + * + * @param context the application [Context] + * @param broadcastReceiverClass the [Class] of the broadcast receiver to set in the created pending intent + * @param channelId [String] containing the notification channel ID + * @param pushTemplate the [ProductRatingPushTemplate] object containing the basic push template data + * @return the created remind later [PendingIntent] + */ + private fun createRatingButtonPendingIntent( + context: Context, + broadcastReceiverClass: Class?, + channelId: String, + pushTemplate: ProductRatingPushTemplate, + ratingButtonSelection: Int, + ): PendingIntent? { + if (broadcastReceiverClass == null) { + return null + } + Log.trace(LOG_TAG, SELF_TAG, "Creating a rating click pending intent from a push template object.") + + val ratingButtonClickIntent = AEPPushNotificationBuilder.createIntent(PushTemplateConstants.IntentActions.RATING_ICON_CLICKED, pushTemplate) + broadcastReceiverClass.let { + ratingButtonClickIntent.setClass(context.applicationContext, broadcastReceiverClass) + } + ratingButtonClickIntent.putExtra( + PushTemplateConstants.IntentKeys.RATING_SELECTED, + ratingButtonSelection.toString() + ) + ratingButtonClickIntent.putExtra( + PushTemplateConstants.PushPayloadKeys.CHANNEL_ID, + channelId + ) + + return PendingIntent.getBroadcast( + context, + pushTemplate.tag.hashCode() + ratingButtonSelection, + ratingButtonClickIntent, + PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + } +} diff --git a/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/builders/TimerNotificationBuilder.kt b/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/builders/TimerNotificationBuilder.kt new file mode 100644 index 00000000..9845ba90 --- /dev/null +++ b/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/builders/TimerNotificationBuilder.kt @@ -0,0 +1,194 @@ +/* + Copyright 2024 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. +*/ + +package com.adobe.marketing.mobile.notificationbuilder.internal.builders + +import android.app.Activity +import android.app.AlarmManager +import android.app.NotificationManager +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.SystemClock +import android.widget.RemoteViews +import androidx.core.app.NotificationCompat +import com.adobe.marketing.mobile.notificationbuilder.NotificationConstructionFailedException +import com.adobe.marketing.mobile.notificationbuilder.PushTemplateConstants +import com.adobe.marketing.mobile.notificationbuilder.PushTemplateConstants.LOG_TAG +import com.adobe.marketing.mobile.notificationbuilder.PushTemplateConstants.PushPayloadKeys.TimerKeys +import com.adobe.marketing.mobile.notificationbuilder.R +import com.adobe.marketing.mobile.notificationbuilder.internal.PendingIntentUtils +import com.adobe.marketing.mobile.notificationbuilder.internal.extensions.createNotificationChannelIfRequired +import com.adobe.marketing.mobile.notificationbuilder.internal.extensions.setRemoteViewImage +import com.adobe.marketing.mobile.notificationbuilder.internal.extensions.setTimerTextColor +import com.adobe.marketing.mobile.notificationbuilder.internal.templates.TimerPushTemplate +import com.adobe.marketing.mobile.services.Log +import com.adobe.marketing.mobile.util.TimeUtils + +private const val TAG = "TimerNotificationBuilder" + +/** + * Object responsible for constructing a [NotificationCompat.Builder] object containing a timer push template notification. + */ +internal object TimerNotificationBuilder { + + /** + * Constructs a notification for the timer push template + * + * @param context the context + * @param template the timer push template + * @param trackerActivityClass the tracker + * @param broadcastReceiverClass the broadcast receiver + * @return the notification builder + */ + @Throws(NotificationConstructionFailedException::class) + fun construct( + context: Context, + template: TimerPushTemplate, + trackerActivityClass: Class?, + broadcastReceiverClass: Class? + ): NotificationCompat.Builder { + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { + throw NotificationConstructionFailedException("Timer push notification on devices below Android N is not supported.") + } + + if (!isExactAlarmsAllowed(context)) { + throw NotificationConstructionFailedException("Exact alarms are not allowed on this device. Ignoring to build Timer template push notifications.") + } + + Log.trace(LOG_TAG, TAG, "Building a timer template push notification.") + + val notificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + // create the notification channel if needed + val channelIdToUse = notificationManager.createNotificationChannelIfRequired(context, template) + + // check if the template is expired + val isExpired = template.isExpired() + val (smallLayout, expandedLayout) = initializeLayouts(context, isExpired) + + // create the notification builder with the common settings applied + val notificationBuilder = AEPPushNotificationBuilder.construct( + context, + template, + channelIdToUse, + trackerActivityClass, + smallLayout, + expandedLayout, + R.id.basic_expanded_layout + ) + + // add text to collapsed layout + smallLayout.setTextViewText(R.id.notification_title, template.timerContent.title) + smallLayout.setTextViewText(R.id.notification_body, template.timerContent.body) + + // add text to expanded layout + expandedLayout.setTextViewText(R.id.notification_title, template.timerContent.title) + expandedLayout.setTextViewText(R.id.notification_body_expanded, template.timerContent.expandedBody) + expandedLayout.setRemoteViewImage(template.timerContent.imageUrl, R.id.expanded_template_image) + + if (!isExpired) { + val remainingTimeInSeconds = template.expiryTime - TimeUtils.getUnixTimeInSeconds() + // set the timer clock + setTimerClock(smallLayout, expandedLayout, remainingTimeInSeconds, template.timerColor) + + // create the intent for the timer expiry + val intent = createIntent(template) + broadcastReceiverClass?.let { + intent.setClass(context, broadcastReceiverClass) + } + + // create the pending intent for the timer expiry + PendingIntentUtils.scheduleNotification(context, intent, broadcastReceiverClass, TimeUtils.getUnixTimeInSeconds() + remainingTimeInSeconds) + } else { + // Before displaying the expired view, check if the notification is still active + val notification = notificationManager.activeNotifications.find { it.id == template.tag?.hashCode() } + if (notification == null) { + Log.debug( + LOG_TAG, TAG, + "Notification with tag '${template.tag}' is not present in the system tray. " + + "The timer notification has already been dismissed. The expired view will not be displayed." + ) + throw NotificationConstructionFailedException("Timer Notification cancelled. Expired view will not be displayed.") + } + } + + return notificationBuilder + } + + /** + * Initializes the layouts for the notification + * + * @param context the context + * @param isExpired whether the template is expired + * @return the collapsed and expanded layouts + */ + private fun initializeLayouts(context: Context, isExpired: Boolean): Pair { + val packageName = context.packageName + val smallLayoutRes = if (isExpired) R.layout.push_template_collapsed else R.layout.push_template_timer_collapsed + val expandedLayoutRes = if (isExpired) R.layout.push_template_expanded else R.layout.push_template_timer_expanded + + val smallLayout = RemoteViews(packageName, smallLayoutRes) + val expandedLayout = RemoteViews(packageName, expandedLayoutRes) + + return smallLayout to expandedLayout + } + + /** + * Sets the timer on the notification + * + * @param smallLayout the collapsed layout + * @param expandedLayout the expanded layout + * @param remainingTime the remaining time for the timer + */ + private fun setTimerClock(smallLayout: RemoteViews, expandedLayout: RemoteViews, remainingTime: Long, timerColor: String?) { + val remainingTimeWithSystemClock = SystemClock.elapsedRealtime() + (remainingTime * 1000) + + smallLayout.setChronometer(R.id.timer_text, remainingTimeWithSystemClock, null, true) + expandedLayout.setChronometer(R.id.timer_text, remainingTimeWithSystemClock, null, true) + smallLayout.setTimerTextColor(timerColor, R.id.timer_text) + expandedLayout.setTimerTextColor(timerColor, R.id.timer_text) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + smallLayout.setChronometerCountDown(R.id.timer_text, true) + expandedLayout.setChronometerCountDown(R.id.timer_text, true) + } + } + + /** + * Creates an intent for the timer expiry + * + * @param template the timer push template + * @return the intent for the timer expiry + */ + private fun createIntent(template: TimerPushTemplate): Intent { + val intent = AEPPushNotificationBuilder.createIntent(PushTemplateConstants.IntentActions.TIMER_EXPIRED, template) + + // remove timer to prevent countdown from being recreated + intent.removeExtra(TimerKeys.TIMER_DURATION) + intent.removeExtra(TimerKeys.TIMER_END_TIME) + return intent + } + + /** + * Checks if exact alarms are allowed on the device + * + * @param context the context + * @return true if exact alarms are allowed, false otherwise + */ + private fun isExactAlarmsAllowed(context: Context): Boolean { + return Build.VERSION.SDK_INT < Build.VERSION_CODES.S || + (context.getSystemService(Context.ALARM_SERVICE) as AlarmManager).canScheduleExactAlarms() + } +} diff --git a/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/builders/ZeroBezelNotificationBuilder.kt b/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/builders/ZeroBezelNotificationBuilder.kt new file mode 100644 index 00000000..e790c970 --- /dev/null +++ b/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/builders/ZeroBezelNotificationBuilder.kt @@ -0,0 +1,87 @@ +/* + Copyright 2024 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. +*/ + +package com.adobe.marketing.mobile.notificationbuilder.internal.builders + +import android.app.Activity +import android.app.NotificationManager +import android.content.Context +import android.view.View +import android.widget.RemoteViews +import androidx.core.app.NotificationCompat +import com.adobe.marketing.mobile.notificationbuilder.NotificationConstructionFailedException +import com.adobe.marketing.mobile.notificationbuilder.PushTemplateConstants.LOG_TAG +import com.adobe.marketing.mobile.notificationbuilder.R +import com.adobe.marketing.mobile.notificationbuilder.internal.PushTemplateImageUtils +import com.adobe.marketing.mobile.notificationbuilder.internal.extensions.createNotificationChannelIfRequired +import com.adobe.marketing.mobile.notificationbuilder.internal.templates.ZeroBezelPushTemplate +import com.adobe.marketing.mobile.services.Log + +internal object ZeroBezelNotificationBuilder { + private const val SELF_TAG = "ZeroBezelNotificationBuilder" + + @Throws(NotificationConstructionFailedException::class) + fun construct( + context: Context, + pushTemplate: ZeroBezelPushTemplate, + trackerActivityClass: Class? + ): NotificationCompat.Builder { + Log.trace(LOG_TAG, SELF_TAG, "Building a zero bezel template push notification.") + val packageName = context.packageName + val smallLayout = RemoteViews(packageName, R.layout.push_template_zero_bezel_collapsed) + val expandedLayout = RemoteViews(packageName, R.layout.push_template_zero_bezel_expanded) + + // download and cache the image used in the notification + val downloadedImageCount = + PushTemplateImageUtils.cacheImages(listOf(pushTemplate.imageUrl)) + + // Check if the image was downloaded + if (downloadedImageCount > 0) { + // set the image on the notification if it was downloaded + val pushImage = + PushTemplateImageUtils.getCachedImage(pushTemplate.imageUrl) + expandedLayout.setImageViewBitmap(R.id.expanded_template_image, pushImage) + + // only set image on the collapsed view if the style is "img" + if (pushTemplate.collapsedStyle == ZeroBezelPushTemplate.ZeroBezelStyle.IMAGE) { + smallLayout.setImageViewBitmap(R.id.collapsed_template_image, pushImage) + } else { + smallLayout.setViewVisibility(R.id.collapsed_template_image, View.GONE) + smallLayout.setViewVisibility(R.id.gradient_template_image, View.GONE) + } + } else { + Log.trace(LOG_TAG, SELF_TAG, "No image found for zero bezel push template.") + // hide the image views if no image was downloaded + expandedLayout.setViewVisibility(R.id.expanded_template_image, View.GONE) + expandedLayout.setViewVisibility(R.id.gradient_template_image, View.GONE) + smallLayout.setViewVisibility(R.id.collapsed_template_image, View.GONE) + smallLayout.setViewVisibility(R.id.gradient_template_image, View.GONE) + } + + // create the notification channel if required + val notificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val channelIdToUse: String = notificationManager.createNotificationChannelIfRequired( + context, pushTemplate + ) + + // create the notification builder with the common settings applied + return AEPPushNotificationBuilder.construct( + context, + pushTemplate, + channelIdToUse, + trackerActivityClass, + smallLayout, + expandedLayout, + R.id.zero_bezel_expanded_layout + ) + } +} diff --git a/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/extensions/AppResourceExtensions.kt b/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/extensions/AppResourceExtensions.kt new file mode 100644 index 00000000..8b4c7c40 --- /dev/null +++ b/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/extensions/AppResourceExtensions.kt @@ -0,0 +1,74 @@ +/* + Copyright 2024 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. +*/ + +package com.adobe.marketing.mobile.notificationbuilder.internal.extensions + +import android.content.ContentResolver +import android.content.Context +import android.content.pm.PackageManager +import android.net.Uri +import com.adobe.marketing.mobile.notificationbuilder.PushTemplateConstants.LOG_TAG +import com.adobe.marketing.mobile.services.Log + +private const val SELF_TAG = "AppResourceExtensions" + +/** + * Returns the resource id for the drawable with the given name. The file must be in the + * res/drawable directory. If the drawable file is not found, 0 is returned. + * + * @param iconName the name of the icon file + * @return the resource id for the icon with the given name + */ +internal fun Context.getIconWithResourceName( + iconName: String? +): Int { + return if (iconName.isNullOrEmpty()) { + 0 + } else resources.getIdentifier(iconName, "drawable", packageName) +} + +/** + * Returns the default application icon. + * + * @return the resource id for the default application icon + */ +internal fun Context.getDefaultAppIcon(): Int { + val packageName = packageName + try { + return packageManager.getApplicationInfo(packageName, 0).icon + } catch (e: PackageManager.NameNotFoundException) { + Log.warning( + LOG_TAG, + SELF_TAG, + "Package manager NameNotFoundException while reading default application icon: ${e.localizedMessage}" + ) + } + return -1 +} + +/** + * Returns the Uri for the sound file with the given name. The sound file must be in the res/raw + * directory. The sound file should be in format of .mp3, .wav, or .ogg + * + * @param soundName [String] containing the name of the sound file + * @return the [Uri] for the sound file with the given name + */ +internal fun Context.getSoundUriForResourceName( + soundName: String? +): Uri { + return Uri.parse( + ContentResolver.SCHEME_ANDROID_RESOURCE + + "://" + + packageName + + "/raw/" + + (soundName ?: "") + ) +} diff --git a/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/extensions/NotificationCompatBuilderExtensions.kt b/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/extensions/NotificationCompatBuilderExtensions.kt new file mode 100644 index 00000000..3d82a06d --- /dev/null +++ b/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/extensions/NotificationCompatBuilderExtensions.kt @@ -0,0 +1,282 @@ +/* + Copyright 2024 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. +*/ + +package com.adobe.marketing.mobile.notificationbuilder.internal.extensions + +import android.app.Activity +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.graphics.Color +import android.media.RingtoneManager +import android.os.Bundle +import androidx.core.app.NotificationCompat +import com.adobe.marketing.mobile.MobileCore +import com.adobe.marketing.mobile.notificationbuilder.PushTemplateConstants +import com.adobe.marketing.mobile.notificationbuilder.PushTemplateConstants.LOG_TAG +import com.adobe.marketing.mobile.notificationbuilder.internal.PendingIntentUtils +import com.adobe.marketing.mobile.notificationbuilder.internal.PushTemplateImageUtils +import com.adobe.marketing.mobile.notificationbuilder.internal.templates.BasicPushTemplate +import com.adobe.marketing.mobile.services.Log +import java.util.Random + +private const val SELF_TAG = "RemoteViewExtensions" + +/** + * Sets the small icon for the notification. If a small icon is received from the payload, the + * same is used. If a small icon is not received from the payload, we use the icon set using + * MobileCore.setSmallIcon(). If a small icon is not set using MobileCore.setSmallIcon(), we use + * the default small icon of the application. + * + * @param context the application [Context] + * @param smallIcon `String` containing the small icon to use + * @param iconColor `String` containing the small icon color code to use + */ +internal fun NotificationCompat.Builder.setSmallIcon( + context: Context, + smallIcon: String?, + iconColor: String? +): NotificationCompat.Builder { + val iconFromPayload = context.getIconWithResourceName(smallIcon) + val iconFromMobileCore = MobileCore.getSmallIconResourceID() + val iconResourceId: Int + if (isValidIcon(iconFromPayload)) { + iconResourceId = iconFromPayload + } else if (isValidIcon(iconFromMobileCore)) { + iconResourceId = iconFromMobileCore + } else { + val iconFromApp = context.getDefaultAppIcon() + if (isValidIcon(iconFromApp)) { + iconResourceId = iconFromApp + } else { + Log.warning( + LOG_TAG, + SELF_TAG, + "No valid small icon found. Notification will not be displayed." + ) + return this + } + } + setSmallIcon(iconResourceId) + setSmallIconColor(iconColor) + return this +} + +/** + * Sets a custom color to the notification's small icon. + * + * @param iconColorHex `String` containing a color code to be used in customizing the + * small icon color + */ +private fun NotificationCompat.Builder.setSmallIconColor( + iconColorHex: String? +) { + if (iconColorHex.isNullOrEmpty()) { + Log.trace( + LOG_TAG, + SELF_TAG, + "Empty icon color hex string found, custom color will not be applied to the notification icon." + ) + return + } + + try { + // sets the icon color if provided + val color = Color.parseColor("#$iconColorHex") + setColorized(true).color = color + } catch (exception: IllegalArgumentException) { + Log.warning( + LOG_TAG, + SELF_TAG, + "Unrecognized hex string passed to Color.parseColor(), custom color will not be applied to the notification icon." + ) + } + return +} + +/** + * Sets the sound for the legacy style notification or notification on a device less than API 25. + * If a sound is received from the payload, the same is used. + * If a sound is not received from the payload, the default sound is used. + * + * @param context the application [Context] + * @param customSound [String] containing the custom sound file name to load from the + * bundled assets + */ +internal fun NotificationCompat.Builder.setSound( + context: Context, + customSound: String? +): NotificationCompat.Builder { + if (customSound.isNullOrEmpty()) { + Log.trace( + LOG_TAG, + SELF_TAG, + "No custom sound found in the push template, using the default notification sound." + ) + setSound( + RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION) + ) + return this + } + Log.trace( + LOG_TAG, + SELF_TAG, + "Setting sound from bundle named $customSound." + ) + setSound( + context.getSoundUriForResourceName( + customSound + ) + ) + return this +} + +/** + * Sets the provided image url as the large icon for the legacy style notification. If a large icon url is received + * from the payload, the image is downloaded and the notification style is set to + * BigPictureStyle. If large icon url is not received from the payload, default style is used + * for the notification. + * + * @param imageUrl [String] containing the image url + * @param title `String` containing the title + * @param bodyText `String` containing the body text + */ +internal fun NotificationCompat.Builder.setLargeIcon( + imageUrl: String?, + title: String?, + bodyText: String? +): NotificationCompat.Builder { + // Quick bail out if there is no image url + if (imageUrl.isNullOrEmpty()) return this + val downloadedIconCount: Int = PushTemplateImageUtils.cacheImages( + listOf(imageUrl) + ) + + // Bail out if the download fails + if (downloadedIconCount == 0) { + return this + } + + val bitmap = PushTemplateImageUtils.getCachedImage(imageUrl) + setLargeIcon(bitmap) + val bigPictureStyle = NotificationCompat.BigPictureStyle() + bigPictureStyle.bigPicture(bitmap) + bigPictureStyle.bigLargeIcon(null) + bigPictureStyle.setBigContentTitle(title) + bigPictureStyle.setSummaryText(bodyText) + setStyle(bigPictureStyle) + return this +} + +/** + * Sets the click action for the notification. + * + * @param context the application [Context] + * @param trackerActivityClass the [Class] of the activity to set in the created pending intent for tracking purposes + * @param actionUri `String` containing the action uri + * @param intentExtras the [Bundle] containing the extras to be added to the intent + */ +internal fun NotificationCompat.Builder.setNotificationClickAction( + context: Context, + trackerActivityClass: Class?, + actionUri: String?, + intentExtras: Bundle? +): NotificationCompat.Builder { + val pendingIntent: PendingIntent? = PendingIntentUtils.createPendingIntentForTrackerActivity( + context, + trackerActivityClass, + actionUri, + null, + intentExtras + ) + setContentIntent(pendingIntent) + return this +} + +/** + * Sets the delete action for the notification. + * + * @param context the application [Context] + * @param trackerActivityClass the [Class] of the activity to set in the created pending intent for tracking purposes + * notification + */ +internal fun NotificationCompat.Builder.setNotificationDeleteAction( + context: Context, + trackerActivityClass: Class? +): NotificationCompat.Builder { + val deleteIntent = Intent(PushTemplateConstants.NotificationAction.DISMISSED) + trackerActivityClass?.let { + deleteIntent.setClass(context.applicationContext, trackerActivityClass) + } + deleteIntent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) + val intent = PendingIntent.getActivity( + context, + Random().nextInt(), + deleteIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + setDeleteIntent(intent) + return this +} + +/** + * Adds action buttons for the notification. + * + * @param context the application [Context] + * @param trackerActivityClass the [Activity] class to use as the tracker activity + * @param actionButtons list of [BasicPushTemplate.ActionButton] containing action buttons to attach + * to the notification + * @param intentExtras the [Bundle] containing the extras to be added to the intent + */ +internal fun NotificationCompat.Builder.addActionButtons( + context: Context, + trackerActivityClass: Class?, + actionButtons: List?, + intentExtras: Bundle? +): NotificationCompat.Builder { + if (actionButtons.isNullOrEmpty()) { + return this + } + for (eachButton in actionButtons) { + val pendingIntent: PendingIntent? = + if (eachButton.type === PushTemplateConstants.ActionType.DEEPLINK || + eachButton.type === PushTemplateConstants.ActionType.WEBURL + ) { + PendingIntentUtils.createPendingIntentForTrackerActivity( + context, + trackerActivityClass, + eachButton.link, + eachButton.label, + intentExtras + ) + } else { + PendingIntentUtils.createPendingIntentForTrackerActivity( + context, + trackerActivityClass, + null, + eachButton.label, + intentExtras + ) + } + addAction(0, eachButton.label, pendingIntent) + } + return this +} + +/** + * Checks if the icon is valid. + * + * @param icon the icon to be checked + * @return true if the icon is valid, false otherwise + */ +private fun isValidIcon(icon: Int): Boolean { + return icon > 0 +} diff --git a/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/extensions/NotificationManagerExtensions.kt b/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/extensions/NotificationManagerExtensions.kt new file mode 100644 index 00000000..58abcf0f --- /dev/null +++ b/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/extensions/NotificationManagerExtensions.kt @@ -0,0 +1,84 @@ +/* + Copyright 2024 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. +*/ + +package com.adobe.marketing.mobile.notificationbuilder.internal.extensions + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.media.RingtoneManager +import android.os.Build +import com.adobe.marketing.mobile.notificationbuilder.PushTemplateConstants +import com.adobe.marketing.mobile.notificationbuilder.PushTemplateConstants.LOG_TAG +import com.adobe.marketing.mobile.notificationbuilder.internal.templates.AEPPushTemplate +import com.adobe.marketing.mobile.services.Log + +private const val SELF_TAG = "NotificationManagerExtensions" + +/** + * Creates a notification channel if the device is running on Android O or higher. If the channel + * already exists, the same channel is used. A default channel ID will be used if no channel ID + * is received from the payload. + * + * @param context the application [Context] + * @param template the push template object + * @return A [String] containing the created or existing channel ID + */ +internal fun NotificationManager.createNotificationChannelIfRequired( + context: Context, + template: AEPPushTemplate +): String { + // create a silent notification channel if push is from intent + // if not from intent and channel id is not provided, use the default channel id + val channelIdToUse = + if (template.isFromIntent) PushTemplateConstants.DefaultValues.SILENT_NOTIFICATION_CHANNEL_ID + else template.channelId ?: PushTemplateConstants.DefaultValues.DEFAULT_CHANNEL_ID + + // No channel creation required. + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return channelIdToUse + } + + // Don't create a channel if it already exists + if (getNotificationChannel(channelIdToUse) != null) { + Log.trace( + LOG_TAG, + SELF_TAG, + "Using previously created notification channel: $channelIdToUse." + ) + return channelIdToUse + } + + // Create a channel + val channel = NotificationChannel( + channelIdToUse, + if (template.isFromIntent) PushTemplateConstants.DefaultValues.SILENT_CHANNEL_NAME else PushTemplateConstants.DefaultValues.DEFAULT_CHANNEL_NAME, + template.getNotificationImportance() + ) + + // Add a sound if required. + if (template.isFromIntent) { + channel.setSound(null, null) + } else { + val sound = if (template.sound.isNullOrEmpty()) { + RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION) + } else context.getSoundUriForResourceName(template.sound) + channel.setSound(sound, null) + } + + Log.trace( + LOG_TAG, + SELF_TAG, + "Creating a new notification channel with ID: ${template.channelId}. ${if (template.sound.isNullOrEmpty()) "and default sound." else "and custom sound: ${template.sound}."}" + ) + createNotificationChannel(channel) + return channelIdToUse +} diff --git a/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/extensions/RemoteViewsExtensions.kt b/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/extensions/RemoteViewsExtensions.kt new file mode 100644 index 00000000..61520901 --- /dev/null +++ b/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/extensions/RemoteViewsExtensions.kt @@ -0,0 +1,268 @@ +/* + Copyright 2024 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. +*/ + +package com.adobe.marketing.mobile.notificationbuilder.internal.extensions + +import android.app.Activity +import android.app.PendingIntent +import android.content.Context +import android.graphics.Color +import android.os.Bundle +import android.view.View +import android.widget.RemoteViews +import com.adobe.marketing.mobile.notificationbuilder.PushTemplateConstants +import com.adobe.marketing.mobile.notificationbuilder.PushTemplateConstants.LOG_TAG +import com.adobe.marketing.mobile.notificationbuilder.internal.PendingIntentUtils +import com.adobe.marketing.mobile.notificationbuilder.internal.PushTemplateImageUtils +import com.adobe.marketing.mobile.services.Log +import com.adobe.marketing.mobile.services.ServiceProvider +import com.adobe.marketing.mobile.util.UrlUtils + +private const val SELF_TAG = "RemoteViewExtensions" + +/** + * Sets a provided color hex string to a UI element contained in a specified [RemoteViews] + * view. + * + * @param elementId [Int] containing the resource id of the UI element + * @param colorHex [String] containing the color hex string + * @param methodName `String` containing the method to be called on the UI element to + * update the color + * @param viewFriendlyName `String` containing the friendly name of the view to be used + * for logging purposes + */ +internal fun RemoteViews.setElementColor( + elementId: Int, + colorHex: String?, + methodName: String, + viewFriendlyName: String +) { + if (colorHex.isNullOrEmpty()) { + Log.debug( + LOG_TAG, + SELF_TAG, + "Empty color hex string found, custom color will not be applied to $viewFriendlyName." + ) + return + } + + try { + setInt(elementId, methodName, Color.parseColor(colorHex)) + } catch (exception: IllegalArgumentException) { + Log.warning( + LOG_TAG, + SELF_TAG, + "Unrecognized hex string passed to Color.parseColor(), custom color will not be applied to $viewFriendlyName." + ) + } +} + +/** + * Sets custom colors to the notification background. + * + * @param backgroundColor [String] containing the hex color code for the notification background + * @param containerViewId [Int] containing the resource id of the push template notification RemoteViews + */ +internal fun RemoteViews.setNotificationBackgroundColor( + backgroundColor: String?, + containerViewId: Int +) { + // get custom color from hex string and set it the notification background + setElementColor( + containerViewId, + "#$backgroundColor", + PushTemplateConstants.MethodNames.SET_BACKGROUND_COLOR, + PushTemplateConstants.FriendlyViewNames.NOTIFICATION_BACKGROUND + ) +} + +/** + * Sets custom color to the timer text. + * + * @param timerTextColor [String] containing the hex color code for the timer text + * @param containerViewId [Int] containing the resource id of the chronometer running timer + */ +internal fun RemoteViews.setTimerTextColor( + timerTextColor: String?, + containerViewId: Int +) { + // get custom color from hex string and set it to the timer text color + setElementColor( + containerViewId, + "#$timerTextColor", + PushTemplateConstants.MethodNames.SET_TEXT_COLOR, + PushTemplateConstants.FriendlyViewNames.TIMER_TEXT + ) +} + +/** + * Sets custom colors to the notification title text. + * + * @param titleTextColor [String] containing the hex color code for the notification title text + * @param containerViewId [Int] containing the resource id of the push template notification RemoteViews + */ +internal fun RemoteViews.setNotificationTitleTextColor( + titleTextColor: String?, + containerViewId: Int +) { + // get custom color from hex string and set it to the notification title + setElementColor( + containerViewId, + "#$titleTextColor", + PushTemplateConstants.MethodNames.SET_TEXT_COLOR, + PushTemplateConstants.FriendlyViewNames.NOTIFICATION_TITLE + ) +} + +/** + * Sets custom colors to the notification body text. + * + * @param expandedBodyTextColor [String] containing the hex color code for the expanded + * notification body text + * @param containerViewId [Int] containing the resource id of the push template notification RemoteViews + */ +internal fun RemoteViews.setNotificationBodyTextColor( + expandedBodyTextColor: String?, + containerViewId: Int +) { + // get custom color from hex string and set it the notification body text + setElementColor( + containerViewId, + "#$expandedBodyTextColor", + PushTemplateConstants.MethodNames.SET_TEXT_COLOR, + PushTemplateConstants.FriendlyViewNames.NOTIFICATION_BODY_TEXT + ) +} + +/** + * Sets the image for the provided [RemoteViews]. If a image contains a filename + * only then the image is set from a bundle image resource. If the image contains a URL, + * the image is downloaded then set. + * + * @param image `String` containing the image to use + * + * @return `Boolean` true if the image was set, false otherwise + */ +internal fun RemoteViews.setRemoteViewImage( + image: String?, + containerViewId: Int +): Boolean { + if (image.isNullOrEmpty()) { + Log.warning( + LOG_TAG, + SELF_TAG, + "Null or empty image string found, image will not be applied." + ) + setViewVisibility(containerViewId, View.GONE) + return false + } + // logical OR is used here for short circuiting the second condition + // first check if image represents a valid URL + // only if it is not, check for bundled image + return setRemoteImage(image, containerViewId) || setBundledImage(image, containerViewId) +} + +/** + * Sets the click action for the specified view in the custom push template [RemoteViews]. + * + * @param context the application [Context] + * @param trackerActivityClass the [Class] of the activity to set in the created pending intent for tracking purposes + * template notification + * @param targetViewResourceId [Int] containing the resource id of the view to attach the click action + * @param actionUri [String] containing the action uri defined for the push template image + * @param actionId the [String] containing action id for tracking purposes + * @param intentExtra the [Bundle] containing the extras to be added to the intent + */ +internal fun RemoteViews.setRemoteViewClickAction( + context: Context, + trackerActivityClass: Class?, + targetViewResourceId: Int, + actionUri: String?, + actionId: String?, + intentExtra: Bundle? +) { + Log.trace( + LOG_TAG, + SELF_TAG, + "Setting remote view click action uri: $actionUri." + ) + + val pendingIntent: PendingIntent? = + PendingIntentUtils.createPendingIntentForTrackerActivity( + context, + trackerActivityClass, + actionUri, + actionId, + intentExtra + ) + setOnClickPendingIntent(targetViewResourceId, pendingIntent) +} + +/** + * Sets the image for the provided [RemoteViews] by downloading the image from the provided URL. + * If the image cannot be downloaded, the image visibility is set to [View.GONE]. + * + * @param imageUrl `String` containing the image URL to download and use + * @param containerViewId [Int] containing the resource id of the view to attach the image to + * + * @return `Boolean` true if the image was set, false otherwise + */ +internal fun RemoteViews.setRemoteImage( + imageUrl: String?, + containerViewId: Int +): Boolean { + if (!UrlUtils.isValidUrl(imageUrl)) { + return false + } + val downloadedIconCount = PushTemplateImageUtils.cacheImages(listOf(imageUrl)) + if (downloadedIconCount == 0) { + Log.warning( + LOG_TAG, + SELF_TAG, + "Unable to download an image from URL $imageUrl, image will not be applied." + ) + setViewVisibility(containerViewId, View.GONE) + return false + } + setImageViewBitmap( + containerViewId, + PushTemplateImageUtils.getCachedImage(imageUrl) + ) + return true +} + +/** + * Sets the image resource bundled with the app for the provided [RemoteViews]. + * If the resource does not exist, the [RemoteViews] visibility is set to [View.GONE]. + * + * @param image `String` containing the image to use + * @param containerViewId [Int] containing the resource id of the view to attach the image to + * + * @return `Boolean` true if the image was set, false otherwise + */ +internal fun RemoteViews.setBundledImage( + image: String?, + containerViewId: Int +): Boolean { + val bundledIconId: Int? = ServiceProvider.getInstance() + .appContextService.applicationContext?.getIconWithResourceName(image) + if (bundledIconId == null || bundledIconId == 0) { + Log.warning( + LOG_TAG, + SELF_TAG, + "Unable to find a bundled image with name $image, image will not be applied." + ) + setViewVisibility(containerViewId, View.GONE) + return false + } + setImageViewResource(containerViewId, bundledIconId) + return true +} diff --git a/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/templates/AEPPushTemplate.kt b/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/templates/AEPPushTemplate.kt new file mode 100644 index 00000000..c500a985 --- /dev/null +++ b/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/templates/AEPPushTemplate.kt @@ -0,0 +1,168 @@ +/* + Copyright 2024 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. +*/ + +package com.adobe.marketing.mobile.notificationbuilder.internal.templates + +import android.app.NotificationManager +import android.os.Build +import androidx.annotation.RequiresApi +import com.adobe.marketing.mobile.notificationbuilder.NotificationPriority +import com.adobe.marketing.mobile.notificationbuilder.NotificationVisibility +import com.adobe.marketing.mobile.notificationbuilder.PushTemplateConstants.ActionType +import com.adobe.marketing.mobile.notificationbuilder.PushTemplateConstants.PushPayloadKeys +import com.adobe.marketing.mobile.notificationbuilder.internal.PushTemplateType +import com.adobe.marketing.mobile.notificationbuilder.internal.util.IntentData +import com.adobe.marketing.mobile.notificationbuilder.internal.util.NotificationData + +/** + * This class is used to parse the push template data payload or an intent and provide the necessary information + * to build a notification. + */ +internal sealed class AEPPushTemplate(val data: NotificationData) { + + // Required, title of the message shown in the collapsed and expanded push template layouts + internal val title: String + + // Required, body of the message shown in the collapsed push template layout + internal val body: String + + // Required, Version of the payload assigned by the authoring UI. + internal val payloadVersion: String + + // begin optional values + // Optional, sound to play when the notification is shown + internal val sound: String? + + // Optional, number to show on the badge of the app + internal val badgeCount: Int + + // Optional, priority of the notification + internal val priority: NotificationPriority + + // Optional, visibility of the notification + internal val visibility: NotificationVisibility + + // Optional, notification channel to use when displaying the notification. Only used on Android O and above. + internal val channelId: String? + + // Optional, small icon for the notification + internal val smallIcon: String? + + // Optional, large icon for the notification + internal val largeIcon: String? + + // Optional, image to show in the notification + internal val imageUrl: String? + + // Optional, action type for the notification + internal val actionType: ActionType? + + // Optional, action uri for the notification + internal val actionUri: String? + + // Optional, Body of the message shown in the expanded message layout (setCustomBigContentView) + internal val expandedBodyText: String? + + // Optional, Text color for adb_body and adb_body_ex. Represented as six character hex, e.g. 00FF00 + internal val bodyTextColor: String? + + // Optional, Text color for adb_title. Represented as six character hex, e.g. 00FF00 + internal val titleTextColor: String? + + // Optional, Color for the notification's small icon. Represented as six character hex, e.g. + // 00FF00 + internal val smallIconColor: String? + + // Optional, Color for the notification's background. Represented as six character hex, e.g. + // 00FF00 + internal val backgroundColor: String? + + // Optional, If present and a notification with the same tag is already being shown, the new + // notification replaces the existing one in the notification drawer. + internal val tag: String? + + // Optional, If present sets the "ticker" text, which is sent to accessibility services. + internal val ticker: String? + + // Optional, the type of push template this payload contains + internal val templateType: PushTemplateType? + + // Optional, when set to false or unset, the notification is automatically dismissed when the + // user clicks it in the panel. When set to true, the notification persists even when the user + // clicks it. + internal val isNotificationSticky: Boolean + + // flag to denote if the PushTemplate was built from an intent + internal val isFromIntent: Boolean + + /** + * Initializes the push template with the given NotificationData. + */ + init { + // extract the payload version + payloadVersion = data.getRequiredString(PushPayloadKeys.VERSION) + + // extract the remaining text information + title = data.getRequiredString(PushPayloadKeys.TITLE) + body = data.getRequiredString(PushPayloadKeys.BODY) + expandedBodyText = data.getString(PushPayloadKeys.EXPANDED_BODY_TEXT) + ticker = data.getString(PushPayloadKeys.TICKER) + + // extract the template type + templateType = PushTemplateType.fromString(data.getString(PushPayloadKeys.TEMPLATE_TYPE)) + isFromIntent = data is IntentData + + // extract the basic media information + imageUrl = data.getString(PushPayloadKeys.IMAGE_URL) + + // extract the action information + actionUri = data.getString(PushPayloadKeys.ACTION_URI) + actionType = + ActionType.valueOf(data.getString(PushPayloadKeys.ACTION_TYPE) ?: ActionType.NONE.name) + + // extract the icon information + smallIcon = data.getString(PushPayloadKeys.SMALL_ICON) + ?: data.getString(PushPayloadKeys.LEGACY_SMALL_ICON) + largeIcon = data.getString(PushPayloadKeys.LARGE_ICON) + + // extract the color components + titleTextColor = data.getString(PushPayloadKeys.TITLE_TEXT_COLOR) + bodyTextColor = data.getString(PushPayloadKeys.BODY_TEXT_COLOR) + backgroundColor = data.getString(PushPayloadKeys.BACKGROUND_COLOR) + smallIconColor = data.getString(PushPayloadKeys.SMALL_ICON_COLOR) + + // extract the other notification properties + tag = data.getString(PushPayloadKeys.TAG) + sound = data.getString(PushPayloadKeys.SOUND) + channelId = data.getString(PushPayloadKeys.CHANNEL_ID) + badgeCount = data.getInteger(PushPayloadKeys.BADGE_COUNT) ?: 0 + isNotificationSticky = data.getBoolean(PushPayloadKeys.STICKY) ?: false + + // extract notification priority and visibility + priority = NotificationPriority.fromString(data.getString(PushPayloadKeys.PRIORITY)) + visibility = NotificationVisibility.fromString(data.getString(PushPayloadKeys.VISIBILITY)) + } + + @RequiresApi(api = Build.VERSION_CODES.N) + fun getNotificationImportance(): Int = + notificationImportanceMap[priority.stringValue] ?: NotificationManager.IMPORTANCE_DEFAULT + + companion object { + @RequiresApi(api = Build.VERSION_CODES.N) + internal val notificationImportanceMap: Map = mapOf( + NotificationPriority.PRIORITY_MIN.toString() to NotificationManager.IMPORTANCE_MIN, + NotificationPriority.PRIORITY_LOW.toString() to NotificationManager.IMPORTANCE_LOW, + NotificationPriority.PRIORITY_DEFAULT.toString() to NotificationManager.IMPORTANCE_DEFAULT, + NotificationPriority.PRIORITY_HIGH.toString() to NotificationManager.IMPORTANCE_HIGH, + NotificationPriority.PRIORITY_MAX.toString() to NotificationManager.IMPORTANCE_MAX + ) + } +} diff --git a/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/templates/AutoCarouselPushTemplate.kt b/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/templates/AutoCarouselPushTemplate.kt new file mode 100644 index 00000000..f22ab305 --- /dev/null +++ b/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/templates/AutoCarouselPushTemplate.kt @@ -0,0 +1,21 @@ +/* + Copyright 2024 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. +*/ + +package com.adobe.marketing.mobile.notificationbuilder.internal.templates + +import com.adobe.marketing.mobile.notificationbuilder.internal.util.NotificationData + +/** + * Auto Carousel Push Template + * + * @param data Notification data + */ +internal class AutoCarouselPushTemplate(data: NotificationData) : CarouselPushTemplate(data) diff --git a/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/templates/BasicPushTemplate.kt b/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/templates/BasicPushTemplate.kt new file mode 100644 index 00000000..a8906b54 --- /dev/null +++ b/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/templates/BasicPushTemplate.kt @@ -0,0 +1,146 @@ +/* + Copyright 2024 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. +*/ + +package com.adobe.marketing.mobile.notificationbuilder.internal.templates + +import androidx.annotation.VisibleForTesting +import com.adobe.marketing.mobile.notificationbuilder.PushTemplateConstants.ActionButtons +import com.adobe.marketing.mobile.notificationbuilder.PushTemplateConstants.ActionType +import com.adobe.marketing.mobile.notificationbuilder.PushTemplateConstants.LOG_TAG +import com.adobe.marketing.mobile.notificationbuilder.PushTemplateConstants.PushPayloadKeys +import com.adobe.marketing.mobile.notificationbuilder.internal.util.NotificationData +import com.adobe.marketing.mobile.services.Log +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject + +internal class BasicPushTemplate(data: NotificationData) : AEPPushTemplate(data) { + + private val SELF_TAG = "BasicPushTemplate" + + /** Class representing the action button with label, link and type */ + class ActionButton(val label: String, val link: String?, type: String?) { + val type: ActionType + + init { + this.type = try { + ActionType.valueOf( + type ?: ActionType.NONE.name + ) + } catch (e: IllegalArgumentException) { + Log.warning( + LOG_TAG, SELF_TAG, + "Invalid action button type provided, defaulting to NONE. Error : ${e.localizedMessage}" + ) + ActionType.NONE + } + } + + companion object { + private const val SELF_TAG = "ActionButton" + + /** + * Converts the json object representing an action button to an [ActionButton]. + * Action button must have a non-empty label, type and uri + * + * @param jsonObject [JSONObject] containing the action button details + * @return an [ActionButton] or null if the conversion fails + */ + fun getActionButtonFromJSONObject(jsonObject: JSONObject): ActionButton? { + return try { + val label = jsonObject.getString(ActionButtons.LABEL) + if (label.isEmpty()) { + Log.debug( + LOG_TAG, SELF_TAG, "Label is empty" + ) + return null + } + var uri: String? = null + val type = jsonObject.getString(ActionButtons.TYPE) + if (type == ActionType.WEBURL.name || type == ActionType.DEEPLINK.name) { + uri = jsonObject.optString(ActionButtons.URI) + } + Log.trace( + LOG_TAG, SELF_TAG, + "Creating an ActionButton with label ($label), uri ($uri), and type ($type)." + ) + ActionButton(label, uri, type) + } catch (e: JSONException) { + Log.warning( + LOG_TAG, SELF_TAG, + "Exception in converting actionButtons json string to json object, Error : ${e.localizedMessage}." + ) + null + } + } + } + } + + // Optional, action buttons for the notification + internal val actionButtonsString: String? + + // Optional, list of ActionButton for the notification + internal val actionButtonsList: List? + + // Optional, If present, show a "remind later" button using the value provided as its label + internal val remindLaterText: String? + + // Optional, If present, schedule this notification to be re-delivered at this epoch timestamp (in seconds) provided. + internal val remindLaterTimestamp: Long? + + // Optional, If present, schedule this notification to be re-delivered after this provided time (in seconds). + internal val remindLaterDuration: Int? + + /** + * Initializes the push template with the given NotificationData. + */ + init { + actionButtonsString = data.getString(PushPayloadKeys.ACTION_BUTTONS) + actionButtonsList = getActionButtonsFromString(actionButtonsString) + remindLaterText = data.getString(PushPayloadKeys.REMIND_LATER_TEXT) + remindLaterTimestamp = data.getLong(PushPayloadKeys.REMIND_LATER_TIMESTAMP) + remindLaterDuration = data.getInteger(PushPayloadKeys.REMIND_LATER_DURATION) + } + + /** + * Converts the string containing json array of actionButtons to a list of [ActionButton]. + * + * @param actionButtons [String] containing the action buttons json string + * @return a list of [ActionButton] or null if the conversion fails + */ + @VisibleForTesting + internal fun getActionButtonsFromString(actionButtons: String?): List? { + if (actionButtons == null) { + Log.debug( + LOG_TAG, SELF_TAG, + "Exception in converting actionButtons json string to json object, Error :" + + " actionButtons is null" + ) + return null + } + val actionButtonList = mutableListOf() + try { + val jsonArray = JSONArray(actionButtons) + for (i in 0 until jsonArray.length()) { + val jsonObject = jsonArray.getJSONObject(i) + val button = ActionButton.getActionButtonFromJSONObject(jsonObject) ?: continue + actionButtonList.add(button) + } + } catch (e: JSONException) { + Log.warning( + LOG_TAG, SELF_TAG, + "Exception in converting actionButtons json string to json object, Error : ${e.localizedMessage}" + ) + return null + } + return actionButtonList + } +} diff --git a/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/templates/CarouselPushTemplate.kt b/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/templates/CarouselPushTemplate.kt new file mode 100644 index 00000000..32a7bbe2 --- /dev/null +++ b/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/templates/CarouselPushTemplate.kt @@ -0,0 +1,98 @@ +/* + Copyright 2024 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. +*/ + +package com.adobe.marketing.mobile.notificationbuilder.internal.templates + +import com.adobe.marketing.mobile.notificationbuilder.PushTemplateConstants.CarouselItemKeys +import com.adobe.marketing.mobile.notificationbuilder.PushTemplateConstants.DefaultValues +import com.adobe.marketing.mobile.notificationbuilder.PushTemplateConstants.LOG_TAG +import com.adobe.marketing.mobile.notificationbuilder.PushTemplateConstants.PushPayloadKeys +import com.adobe.marketing.mobile.notificationbuilder.internal.util.NotificationData +import com.adobe.marketing.mobile.services.Log +import org.json.JSONArray +import org.json.JSONException + +internal sealed class CarouselPushTemplate(data: NotificationData) : AEPPushTemplate(data) { + // Optional, Determines how the carousel will be operated. Valid values are "auto" or "manual". + // Default is "auto". + internal val carouselMode: String + + // Required, One or more Items in the carousel defined by the CarouselItem class + internal val carouselItems: MutableList + + // Required, "default" or "filmstrip" + internal val carouselLayout: String + + // Contains the carousel items as a string + internal val rawCarouselItems: String + + data class CarouselItem( + // Required, URI to an image to be shown for the carousel item + val imageUri: String, + // Optional, caption to show when the carousel item is visible + val captionText: String?, + // Optional, URI to handle when the item is touched by the user. If no uri is provided for the item, adb_uri will be handled instead. + val interactionUri: String? + ) + + /** + * Initializes the push template with the given NotificationData. + */ + init { + carouselLayout = data.getRequiredString(PushPayloadKeys.CAROUSEL_LAYOUT) + rawCarouselItems = data.getRequiredString(PushPayloadKeys.CAROUSEL_ITEMS) + carouselMode = data.getString(PushPayloadKeys.CAROUSEL_OPERATION_MODE) + ?: DefaultValues.AUTO_CAROUSEL_MODE + carouselItems = parseCarouselItemsFromString(rawCarouselItems) + } + + companion object { + private const val SELF_TAG = "CarouselPushTemplate" + + operator fun invoke(data: NotificationData): CarouselPushTemplate { + val carouselMode = data.getString(PushPayloadKeys.CAROUSEL_OPERATION_MODE) + ?: DefaultValues.AUTO_CAROUSEL_MODE + return if (carouselMode == DefaultValues.AUTO_CAROUSEL_MODE) { + AutoCarouselPushTemplate(data) + } else + ManualCarouselPushTemplate(data) + } + + private fun parseCarouselItemsFromString(carouselItemsString: String?): MutableList { + val carouselItems = mutableListOf() + if (carouselItemsString.isNullOrEmpty()) { + Log.debug( + LOG_TAG, SELF_TAG, + "No carousel items found in the push template." + ) + return carouselItems + } + try { + val jsonArray = JSONArray(carouselItemsString) + for (i in 0 until jsonArray.length()) { + val item = jsonArray.getJSONObject(i) + val imageUri = item.getString(CarouselItemKeys.IMAGE) + val captionText = item.optString(CarouselItemKeys.TEXT, "") + val interactionUri = item.optString(CarouselItemKeys.URI, "") + carouselItems.add( + CarouselItem(imageUri, captionText, interactionUri) + ) + } + } catch (e: JSONException) { + Log.debug( + LOG_TAG, SELF_TAG, + "Failed to parse carousel items from the push template: ${e.localizedMessage}" + ) + } + return carouselItems + } + } +} diff --git a/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/templates/InputBoxPushTemplate.kt b/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/templates/InputBoxPushTemplate.kt new file mode 100644 index 00000000..5f914d83 --- /dev/null +++ b/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/templates/InputBoxPushTemplate.kt @@ -0,0 +1,43 @@ +/* + Copyright 2024 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. +*/ + +package com.adobe.marketing.mobile.notificationbuilder.internal.templates + +import com.adobe.marketing.mobile.notificationbuilder.PushTemplateConstants.PushPayloadKeys +import com.adobe.marketing.mobile.notificationbuilder.internal.util.NotificationData + +/** + * This class is used to parse the push template data payload or an intent and provide the necessary information + * to build a notification containing an input box. + */ +internal class InputBoxPushTemplate(data: NotificationData) : AEPPushTemplate(data) { + // Required, the intent action name to be used when the user submits the feedback. + internal val inputBoxReceiverName: String + + // Optional, If present, use it as the placeholder text for the text input field. Otherwise, use the default placeholder text of "Reply". + internal val inputTextHint: String? + + // Optional, once feedback has been submitted, use this text as the notification's body + internal val feedbackText: String? + + // Optional, once feedback has been submitted, use this as the notification's image + internal val feedbackImage: String? + + /** + * Initializes the input box push template with the provided data. + */ + init { + inputBoxReceiverName = data.getRequiredString(PushPayloadKeys.INPUT_BOX_RECEIVER_NAME) + inputTextHint = data.getString(PushPayloadKeys.INPUT_BOX_HINT) + feedbackText = data.getString(PushPayloadKeys.INPUT_BOX_FEEDBACK_TEXT) + feedbackImage = data.getString(PushPayloadKeys.INPUT_BOX_FEEDBACK_IMAGE) + } +} diff --git a/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/templates/ManualCarouselPushTemplate.kt b/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/templates/ManualCarouselPushTemplate.kt new file mode 100644 index 00000000..35448e9e --- /dev/null +++ b/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/templates/ManualCarouselPushTemplate.kt @@ -0,0 +1,46 @@ +/* + Copyright 2024 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. +*/ + +package com.adobe.marketing.mobile.notificationbuilder.internal.templates + +import com.adobe.marketing.mobile.notificationbuilder.PushTemplateConstants +import com.adobe.marketing.mobile.notificationbuilder.internal.util.IntentData +import com.adobe.marketing.mobile.notificationbuilder.internal.util.NotificationData + +internal class ManualCarouselPushTemplate(data: NotificationData) : CarouselPushTemplate(data) { + internal var intentAction: String? = null + private set + internal var centerImageIndex: Int = PushTemplateConstants.DefaultValues.NO_CENTER_INDEX_SET + + /** + * Constructs a Manual Carousel Push Template from the provided data. + * If the intent action is not null, then the data is from an intent. + */ + init { + centerImageIndex = getDefaultCarouselIndex(carouselLayout) + if (data is IntentData && data.actionName != null) { + this.intentAction = data.actionName + centerImageIndex = + data.getInteger(PushTemplateConstants.IntentKeys.CENTER_IMAGE_INDEX) + ?: getDefaultCarouselIndex(carouselLayout) + } + } + + companion object { + private fun getDefaultCarouselIndex(carouselLayoutType: String): Int { + return if (carouselLayoutType == PushTemplateConstants.DefaultValues.FILMSTRIP_CAROUSEL_MODE) { + PushTemplateConstants.DefaultValues.FILMSTRIP_CAROUSEL_CENTER_INDEX + } else { + PushTemplateConstants.DefaultValues.MANUAL_CAROUSEL_START_INDEX + } + } + } +} diff --git a/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/templates/MultiIconPushTemplate.kt b/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/templates/MultiIconPushTemplate.kt new file mode 100644 index 00000000..28e4cd8d --- /dev/null +++ b/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/templates/MultiIconPushTemplate.kt @@ -0,0 +1,116 @@ +/* + Copyright 2024 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. +*/ + +package com.adobe.marketing.mobile.notificationbuilder.internal.templates + +import com.adobe.marketing.mobile.notificationbuilder.PushTemplateConstants +import com.adobe.marketing.mobile.notificationbuilder.PushTemplateConstants.DEFAULT_DELETE_ICON_NAME +import com.adobe.marketing.mobile.notificationbuilder.internal.util.NotificationData +import com.adobe.marketing.mobile.services.Log +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject + +internal class MultiIconPushTemplate(data: NotificationData) : AEPPushTemplate(data) { + + private val SELF_TAG = "MultiIconNotificationTemplate" + data class MultiIconTemplateItem( + val iconUrl: String, + val actionType: PushTemplateConstants.ActionType, + val actionUri: String? + ) + + internal val templateItemList: MutableList + internal var cancelIcon: String? = null + + init { + val itemsJson = data.getRequiredString(PushTemplateConstants.PushPayloadKeys.MULTI_ICON_ITEMS) + templateItemList = getTemplateItemList(itemsJson) + ?: throw IllegalArgumentException("Required field \"${PushTemplateConstants.PushPayloadKeys.MULTI_ICON_ITEMS}\" is invalid.") + + if (templateItemList.size < PushTemplateConstants.DefaultValues.ICON_TEMPLATE_MIN_IMAGE_COUNT || + templateItemList.size > PushTemplateConstants.DefaultValues.ICON_TEMPLATE_MAX_IMAGE_COUNT + ) { + throw IllegalArgumentException("\"${PushTemplateConstants.PushPayloadKeys.MULTI_ICON_ITEMS}\" field must have 3 to 5 valid items") + } + + cancelIcon = data.getString(PushTemplateConstants.PushPayloadKeys.MULTI_ICON_CLOSE_BUTTON) + if (cancelIcon.isNullOrEmpty()) { + cancelIcon = DEFAULT_DELETE_ICON_NAME + } + } + + private fun getTemplateItemList(templateIconListJsonString: String?): MutableList? { + if (templateIconListJsonString.isNullOrEmpty()) { + Log.warning( + PushTemplateConstants.LOG_TAG, + SELF_TAG, + "Exception in converting rating uri json string to json array, Error :" + + " templateIconList Json String is null or empty" + ) + return null + } + val iconItemsList = mutableListOf() + try { + val jsonArray = JSONArray(templateIconListJsonString) + for (i in 0 until jsonArray.length()) { + val jsonObject = jsonArray.getJSONObject(i) + val multiIconAction = getIconItemFromJsonObject(jsonObject) + multiIconAction?.let { iconItemsList.add(it) } + } + } catch (e: JSONException) { + Log.debug( + PushTemplateConstants.LOG_TAG, + SELF_TAG, + "Exception in converting template action json string to json array, Error : ${e.localizedMessage}" + ) + return null + } + return iconItemsList + } + + private fun getIconItemFromJsonObject(jsonObject: JSONObject): MultiIconTemplateItem? { + return try { + val imageUri = jsonObject.getString(PushTemplateConstants.MultiIconTemplateKeys.IMG) + // In case of invalid image URI, return null as icon is mandatory + if (imageUri.isNullOrEmpty()) { + Log.debug( + PushTemplateConstants.LOG_TAG, + SELF_TAG, + "Image uri is empty, cannot create icon item." + ) + return null + } + var uri: String? = null + val actionTypeString = jsonObject.getString(PushTemplateConstants.MultiIconTemplateKeys.TYPE) + var actionType = if (actionTypeString.isNullOrEmpty()) { + PushTemplateConstants.ActionType.NONE + } else { + PushTemplateConstants.ActionType.valueOf(actionTypeString) + } + if (actionType == PushTemplateConstants.ActionType.WEBURL || actionType == PushTemplateConstants.ActionType.DEEPLINK) { + uri = jsonObject.getString(PushTemplateConstants.MultiIconTemplateKeys.URI) + if (uri.isNullOrEmpty()) { + Log.debug( + PushTemplateConstants.LOG_TAG, + SELF_TAG, + "Uri is empty for action type $actionType, cannot create icon item." + ) + return null + } + } + + MultiIconTemplateItem(imageUri, actionType, uri) + } catch (e: Exception) { + null + } + } +} diff --git a/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/templates/ProductCatalogPushTemplate.kt b/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/templates/ProductCatalogPushTemplate.kt new file mode 100644 index 00000000..fde3a03b --- /dev/null +++ b/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/templates/ProductCatalogPushTemplate.kt @@ -0,0 +1,135 @@ +/* + Copyright 2024 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. +*/ + +package com.adobe.marketing.mobile.notificationbuilder.internal.templates + +import com.adobe.marketing.mobile.notificationbuilder.PushTemplateConstants +import com.adobe.marketing.mobile.notificationbuilder.PushTemplateConstants.CatalogItemKeys +import com.adobe.marketing.mobile.notificationbuilder.PushTemplateConstants.DefaultValues +import com.adobe.marketing.mobile.notificationbuilder.PushTemplateConstants.LOG_TAG +import com.adobe.marketing.mobile.notificationbuilder.PushTemplateConstants.PushPayloadKeys +import com.adobe.marketing.mobile.notificationbuilder.internal.util.NotificationData +import com.adobe.marketing.mobile.services.Log +import org.json.JSONArray +import org.json.JSONException + +internal class ProductCatalogPushTemplate(data: NotificationData) : AEPPushTemplate(data) { + // Required, Text to be shown on the CTA button + internal val ctaButtonText: String + + // Required, Color for the CTA button. Represented as six character hex, e.g. 00FF00 + internal val ctaButtonColor: String + + // Required, Color for the CTA button text. Represented as six character hex, e.g. 00FF00 + internal val ctaButtonTextColor: String + + // Required, URI to be handled when the user clicks the CTA button + internal val ctaButtonUri: String + + // Required, Determines if the layout of the catalog goes left-to-right or top-to-bottom. + // Value will either be "horizontal" (left-to-right) or "vertical" (top-to-bottom). + internal val displayLayout: String + + // Required, Three entries describing the items in the product catalog. + // The value is an encoded JSON string. + internal val rawCatalogItems: String + + // Required, One or more items in the product catalog defined by the CatalogItem class + internal val catalogItems: MutableList + + internal var currentIndex: Int + + data class CatalogItem( + // Required, Text to use in the title if this product is selected + val title: String, + + // Required, Text to use in the body if this product is selected + val body: String, + + // Required, URI to an image to use in notification when this product is selected + val img: String, + + // Required, Price of this product to display when the notification is selected + val price: String, + + // Required, URI to be handled when the user clicks the large image of the selected item + val uri: String + ) + + /** + * Constructs a Product Catalog push template with the given NotificationData. + */ + init { + ctaButtonText = data.getRequiredString(PushPayloadKeys.CATALOG_CTA_BUTTON_TEXT) + ctaButtonColor = data.getRequiredString(PushPayloadKeys.CATALOG_CTA_BUTTON_COLOR) + ctaButtonTextColor = data.getRequiredString(PushPayloadKeys.CATALOG_CTA_BUTTON_TEXT_COLOR) + ctaButtonUri = data.getRequiredString(PushPayloadKeys.CATALOG_CTA_BUTTON_URI) + displayLayout = data.getRequiredString(PushPayloadKeys.CATALOG_LAYOUT) + rawCatalogItems = data.getRequiredString(PushPayloadKeys.CATALOG_ITEMS) + catalogItems = parseCatalogItemsFromString(rawCatalogItems) + currentIndex = data.getInteger(PushTemplateConstants.IntentKeys.CATALOG_ITEM_INDEX) + ?: DefaultValues.PRODUCT_CATALOG_START_INDEX + } + + companion object { + private const val SELF_TAG = "ProductCatalogPushTemplate" + + private fun parseCatalogItemsFromString(catalogItemsString: String?): MutableList { + val catalogItems = mutableListOf() + val jsonArray: JSONArray? + try { + jsonArray = JSONArray(catalogItemsString) + } catch (e: JSONException) { + Log.error( + LOG_TAG, SELF_TAG, + "Exception occurred when creating json array from the catalog items string: ${e.localizedMessage}" + ) + throw IllegalArgumentException("Catalog items string containing a valid json array was not found.") + } + + // fast fail if the array is not the expected size + if (jsonArray.length() != 3) { + throw IllegalArgumentException("3 catalog items are required for a Product Catalog notification.") + } + + for (i in 0 until jsonArray.length()) { + try { + val item = jsonArray.getJSONObject(i) + // all values are required for a catalog item. if any are missing we have an invalid catalog item and we know the notification as a whole is invalid + // as three catalog items are required. + val title = item.getString(CatalogItemKeys.TITLE) + val body = item.getString(CatalogItemKeys.BODY) + val image = item.getString(CatalogItemKeys.IMAGE) + val price = item.getString(CatalogItemKeys.PRICE) + val uri = item.getString(CatalogItemKeys.URI) + + catalogItems.add( + CatalogItem( + title, + body, + image, + price, + uri + ) + ) + } catch (e: JSONException) { + Log.error( + LOG_TAG, + SELF_TAG, + "Failed to parse catalog item at index $i: ${e.localizedMessage}" + ) + throw IllegalArgumentException("3 catalog items are required for a Product Catalog notification.") + } + } + return catalogItems + } + } +} diff --git a/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/templates/ProductRatingPushTemplate.kt b/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/templates/ProductRatingPushTemplate.kt new file mode 100644 index 00000000..a88a1b0b --- /dev/null +++ b/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/templates/ProductRatingPushTemplate.kt @@ -0,0 +1,106 @@ +/* + Copyright 2024 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. +*/ + +package com.adobe.marketing.mobile.notificationbuilder.internal.templates + +import com.adobe.marketing.mobile.notificationbuilder.PushTemplateConstants +import com.adobe.marketing.mobile.notificationbuilder.internal.util.NotificationData +import com.adobe.marketing.mobile.services.Log +import org.json.JSONArray +import org.json.JSONObject + +internal class ProductRatingPushTemplate(data: NotificationData) : AEPPushTemplate(data) { + + private val SELF_TAG = "ProductRatingPushTemplate" + + class RatingAction(val type: PushTemplateConstants.ActionType, val link: String?) { + companion object { + private const val SELF_TAG = "RatingAction" + + /** + * Converts the json object representing the action on selecting the rating to a [RatingAction]. + * + * @param jsonObject [JSONObject] containing the action details + * @return an [RatingAction] or null if the conversion fails + */ + fun from(jsonObject: JSONObject): RatingAction? { + return try { + var uri: String? = null + val type = PushTemplateConstants.ActionType.valueOf(jsonObject.getString(PushTemplateConstants.RatingAction.TYPE)) + if (type == PushTemplateConstants.ActionType.WEBURL || type == PushTemplateConstants.ActionType.DEEPLINK) { + uri = jsonObject.getString(PushTemplateConstants.RatingAction.URI) + } + Log.trace( + PushTemplateConstants.LOG_TAG, + SELF_TAG, + "Creating a rating action with uri ($uri), and type ($type)." + ) + RatingAction(type, uri) + } catch (e: Exception) { + Log.warning( + PushTemplateConstants.LOG_TAG, + SELF_TAG, + "Exception in converting rating action json string to json object, Error : ${e.localizedMessage}." + ) + null + } + } + } + } + + internal val ratingUnselectedIcon: String + internal val ratingSelectedIcon: String + internal val ratingActionString: String + internal val ratingActionList: List + internal val ratingSelected: Int + + init { + ratingUnselectedIcon = data.getRequiredString(PushTemplateConstants.PushPayloadKeys.RATING_UNSELECTED_ICON) + ratingSelectedIcon = data.getRequiredString(PushTemplateConstants.PushPayloadKeys.RATING_SELECTED_ICON) + ratingActionString = data.getRequiredString(PushTemplateConstants.PushPayloadKeys.RATING_ACTIONS) + ratingActionList = getRatingActionsFromString(ratingActionString) + ?: throw IllegalArgumentException("Required field \"${PushTemplateConstants.PushPayloadKeys.RATING_ACTIONS}\" is invalid.") + if (ratingActionList.size < 3 || ratingActionList.size > 5) { + throw IllegalArgumentException("\"${PushTemplateConstants.PushPayloadKeys.RATING_ACTIONS}\" field must have 3 to 5 rating actions") + } + ratingSelected = data.getInteger(PushTemplateConstants.IntentKeys.RATING_SELECTED) + ?: PushTemplateConstants.ProductRatingKeys.RATING_UNSELECTED + } + + private fun getRatingActionsFromString(ratingActionJsonString: String?): List? { + if (ratingActionJsonString.isNullOrEmpty()) { + Log.debug( + PushTemplateConstants.LOG_TAG, + SELF_TAG, + "Exception in converting rating uri json string to json array, Error :" + + " rating uris is null" + ) + return null + } + val ratingActionList = mutableListOf() + try { + val jsonArray = JSONArray(ratingActionJsonString) + for (i in 0 until jsonArray.length()) { + val jsonObject = jsonArray.getJSONObject(i) + val ratingAction = RatingAction.from(jsonObject) ?: return null + ratingActionList.add(ratingAction) + } + } catch (e: Exception) { + Log.debug( + PushTemplateConstants.LOG_TAG, + SELF_TAG, + "Exception in converting rating uri json string to json array, Error : ${e.localizedMessage}" + ) + return null + } + return ratingActionList + } +} diff --git a/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/templates/TimerPushTemplate.kt b/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/templates/TimerPushTemplate.kt new file mode 100644 index 00000000..7a41eb39 --- /dev/null +++ b/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/templates/TimerPushTemplate.kt @@ -0,0 +1,90 @@ +/* + Copyright 2024 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. +*/ + +package com.adobe.marketing.mobile.notificationbuilder.internal.templates + +import com.adobe.marketing.mobile.notificationbuilder.PushTemplateConstants.PushPayloadKeys.TimerKeys +import com.adobe.marketing.mobile.notificationbuilder.internal.util.NotificationData +import com.adobe.marketing.mobile.util.TimeUtils + +/** + * Class for parsing the data required to display a Timer notification. + */ +internal class TimerPushTemplate(data: NotificationData) : AEPPushTemplate(data) { + + data class TimerContent( + val title: String, + val body: String?, + val expandedBody: String?, + val imageUrl: String? + ) + + internal val alternateTitle: String + internal val alternateBody: String? + internal val alternateExpandedBody: String? + internal val alternateImage: String? + internal val timerColor: String? + internal val expiryTime: Long + internal val timerContent: TimerContent + + /** + * Initialize the TimerPushTemplate with the provided [NotificationData]. + */ + init { + alternateTitle = data.getRequiredString(TimerKeys.ALTERNATE_TITLE) + expiryTime = extractExpiryTime(data) ?: 0 + alternateBody = data.getString(TimerKeys.ALTERNATE_BODY) + alternateExpandedBody = data.getString(TimerKeys.ALTERNATE_EXPANDED_BODY) + alternateImage = data.getString(TimerKeys.ALTERNATE_IMAGE) + timerColor = data.getString(TimerKeys.TIMER_COLOR) + + timerContent = if (isExpired()) { + TimerContent( + title = alternateTitle, + body = alternateBody, + expandedBody = alternateExpandedBody, + imageUrl = alternateImage + ) + } else { + TimerContent( + title = title, + body = body, + expandedBody = expandedBodyText, + imageUrl = imageUrl + ) + } + } + + /** + * Returns true if the timer has expired, false otherwise. + */ + internal fun isExpired(): Boolean { + return expiryTime.let { TimeUtils.getUnixTimeInSeconds() > it } + } + + /** + * Returns the timestamp when the timer will expire. + * + * @return the expiry time in seconds + */ + private fun extractExpiryTime(data: NotificationData): Long? { + val duration = data.getString(TimerKeys.TIMER_DURATION)?.toLongOrNull() + val endTimestamp = data.getString(TimerKeys.TIMER_END_TIME)?.toLongOrNull() + // If duration is provided, calculate the expiry time based on the current time. + // If endTimestamp is provided, use it as the expiry time. + // duration takes precedence over endTimestamp, if both are provided. + return when { + duration != null -> TimeUtils.getUnixTimeInSeconds() + duration + endTimestamp != null -> endTimestamp + else -> null + } + } +} diff --git a/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/templates/ZeroBezelPushTemplate.kt b/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/templates/ZeroBezelPushTemplate.kt new file mode 100644 index 00000000..469654e4 --- /dev/null +++ b/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/templates/ZeroBezelPushTemplate.kt @@ -0,0 +1,43 @@ +/* + Copyright 2024 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. +*/ + +package com.adobe.marketing.mobile.notificationbuilder.internal.templates + +import com.adobe.marketing.mobile.notificationbuilder.PushTemplateConstants.PushPayloadKeys +import com.adobe.marketing.mobile.notificationbuilder.internal.util.NotificationData + +internal class ZeroBezelPushTemplate(data: NotificationData) : AEPPushTemplate(data) { + + internal enum class ZeroBezelStyle(val collapsedStyle: String) { + IMAGE("img"), + TEXT("txt"); + + companion object { + private val zeroBezelStyleMap = values().associateBy { it.collapsedStyle } + internal fun getCollapsedStyleFromString(style: String): ZeroBezelStyle { + return zeroBezelStyleMap[style] ?: TEXT + } + } + } + + internal var collapsedStyle: ZeroBezelStyle + private set + + /** + * Constructs a Zero Bezel Push Template from the provided data. + */ + init { + collapsedStyle = ZeroBezelStyle + .getCollapsedStyleFromString( + data.getString(PushPayloadKeys.ZERO_BEZEL_COLLAPSED_STYLE) ?: "txt" + ) + } +} diff --git a/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/util/IntentData.kt b/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/util/IntentData.kt new file mode 100644 index 00000000..3db61707 --- /dev/null +++ b/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/util/IntentData.kt @@ -0,0 +1,27 @@ +/* + Copyright 2024 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. +*/ + +package com.adobe.marketing.mobile.notificationbuilder.internal.util + +import android.os.Bundle + +/** + * Class responsible for extracting notification data from an intent. + */ +internal class IntentData(private val extras: Bundle, val actionName: String?) : NotificationData { + override fun getString(key: String): String? = extras.getString(key) + override fun getInteger(key: String): Int? = extras.getString(key)?.toIntOrNull() + override fun getBoolean(key: String): Boolean? = extras.getString(key)?.toBoolean() + override fun getLong(key: String): Long? = extras.getString(key)?.toLongOrNull() + override fun getBundle(): Bundle { + return extras + } +} diff --git a/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/util/MapData.kt b/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/util/MapData.kt new file mode 100644 index 00000000..603f8ef2 --- /dev/null +++ b/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/util/MapData.kt @@ -0,0 +1,37 @@ +/* + Copyright 2024 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. +*/ + +package com.adobe.marketing.mobile.notificationbuilder.internal.util + +import android.os.Bundle +import com.adobe.marketing.mobile.util.DataReader + +/** + * Class responsible for extracting notification data from Remote Message Map. + */ +internal class MapData(private val data: Map) : NotificationData { + override fun getString(key: String): String? = DataReader.optString(data, key, null) + override fun getInteger(key: String): Int? = + DataReader.optString(data, key, null)?.toIntOrNull() + + override fun getBoolean(key: String): Boolean? = + DataReader.optString(data, key, null)?.toBoolean() + + override fun getLong(key: String): Long? = DataReader.optString(data, key, null)?.toLongOrNull() + + override fun getBundle(): Bundle { + val bundle = Bundle() + for (key in data.keys) { + bundle.putString(key, data[key]) + } + return bundle + } +} diff --git a/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/util/NotificationData.kt b/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/util/NotificationData.kt new file mode 100644 index 00000000..82a6a850 --- /dev/null +++ b/code/notificationbuilder/src/main/java/com/adobe/marketing/mobile/notificationbuilder/internal/util/NotificationData.kt @@ -0,0 +1,69 @@ +/* + Copyright 2024 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. +*/ + +package com.adobe.marketing.mobile.notificationbuilder.internal.util + +import android.os.Bundle + +// Interface for abstracting the source of notification properties +interface NotificationData { + + /** + * Returns the string value for the given key, or throws an exception if the key is not found or the value is null. + * + * @param key the key to retrieve the string value + * @return the string value for the given key + * @throws IllegalArgumentException if the key is not found or the value is null + */ + fun getRequiredString(key: String): String { + return getString(key) + ?: throw IllegalArgumentException("Required push template key $key not found or null") + } + + /** + * Returns the string value for the given key. + * + * @param key the key to retrieve the string value + * @return the string value for the given key, or null if the key is not found + */ + fun getString(key: String): String? + + /** + * Returns the integer value for the given key. + * + * @param key the key to retrieve the integer value + * @return the integer value for the given key, or null if the key is not found or the value is not an integer + */ + fun getInteger(key: String): Int? + + /** + * Returns the boolean value for the given key. + * + * @param key the key to retrieve the boolean value + * @return the boolean value for the given key, or null if the key is not found or the value is not a boolean + */ + fun getBoolean(key: String): Boolean? + + /** + * Returns the long value for the given key. + * + * @param key the key to retrieve the long value + * @return the long value for the given key, or null if the key is not found or the value is not a long + */ + fun getLong(key: String): Long? + + /** + * Returns a [Bundle] containing all the key-value pairs present in the data. + * + * @return a [Bundle] containing all the key-value pairs present in the data + */ + fun getBundle(): Bundle +} diff --git a/code/notificationbuilder/src/main/res/drawable/cross.xml b/code/notificationbuilder/src/main/res/drawable/cross.xml new file mode 100644 index 00000000..ca3baac7 --- /dev/null +++ b/code/notificationbuilder/src/main/res/drawable/cross.xml @@ -0,0 +1,10 @@ + + + diff --git a/code/notificationbuilder/src/main/res/drawable/skipleft.png b/code/notificationbuilder/src/main/res/drawable/skipleft.png new file mode 100644 index 00000000..214c69fb Binary files /dev/null and b/code/notificationbuilder/src/main/res/drawable/skipleft.png differ diff --git a/code/notificationbuilder/src/main/res/drawable/skipright.png b/code/notificationbuilder/src/main/res/drawable/skipright.png new file mode 100644 index 00000000..157f964d Binary files /dev/null and b/code/notificationbuilder/src/main/res/drawable/skipright.png differ diff --git a/code/notificationbuilder/src/main/res/drawable/zero_bazel_gradient.xml b/code/notificationbuilder/src/main/res/drawable/zero_bazel_gradient.xml new file mode 100644 index 00000000..5795e102 --- /dev/null +++ b/code/notificationbuilder/src/main/res/drawable/zero_bazel_gradient.xml @@ -0,0 +1,7 @@ + + + \ No newline at end of file diff --git a/code/notificationbuilder/src/main/res/layout/multi_icon_template_item.xml b/code/notificationbuilder/src/main/res/layout/multi_icon_template_item.xml new file mode 100644 index 00000000..06851d9c --- /dev/null +++ b/code/notificationbuilder/src/main/res/layout/multi_icon_template_item.xml @@ -0,0 +1,12 @@ + + diff --git a/code/notificationbuilder/src/main/res/layout/push_tempate_vertical_catalog.xml b/code/notificationbuilder/src/main/res/layout/push_tempate_vertical_catalog.xml new file mode 100644 index 00000000..da1eaffa --- /dev/null +++ b/code/notificationbuilder/src/main/res/layout/push_tempate_vertical_catalog.xml @@ -0,0 +1,104 @@ + + + + + + + + + +