diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..ae1eec4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,31 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: triage +assignees: wenxi-zeng + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Do '...' +2. '....' +3. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Platform (please complete the following information):** + - Library Version in use: [e.g. 0.0.5] + - Platform being tested: [e.g. Android] + - Integrations in use: [e.g. Firebase, Amplitude] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..63670f1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: triage +assignees: prayansh + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/ISSUE_TEMPLATE/pull_request_template.md b/.github/ISSUE_TEMPLATE/pull_request_template.md new file mode 100644 index 0000000..b971f07 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/pull_request_template.md @@ -0,0 +1,13 @@ +## Purpose +_Describe the problem or feature in addition to a link to the issues._ + +## Approach +_How does this change address the problem?_ + +#### Open Questions and Pre-Merge TODOs +- [ ] Use github checklists. When solved, check the box and explain the answer. + +## Learning +_Describe the research stage_ + +_Links to blog posts, patterns, libraries or addons used to solve this problem_ \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..1421d39 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,40 @@ +name: CI + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + workflow_dispatch: + +permissions: write-all + +jobs: + cancel_previous: + + runs-on: ubuntu-latest + steps: + - uses: styfle/cancel-workflow-action@0.9.1 + with: + workflow_id: ${{ github.event.workflow.id }} + + build: + + needs: cancel_previous + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Grant execute permission for gradlew + run: chmod +x gradlew + - name: cache gradle dependencies + uses: actions/cache@v2 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + - name: Run Tests + run: ./gradlew check \ No newline at end of file diff --git a/.github/workflows/create_jira.yml b/.github/workflows/create_jira.yml new file mode 100644 index 0000000..51ba6a8 --- /dev/null +++ b/.github/workflows/create_jira.yml @@ -0,0 +1,39 @@ +name: Create Jira Ticket + +on: + issues: + types: + - opened + +jobs: + create_jira: + name: Create Jira Ticket + runs-on: ubuntu-latest + environment: IssueTracker + steps: + - name: Checkout + uses: actions/checkout@master + - name: Login + uses: atlassian/gajira-login@master + env: + JIRA_BASE_URL: ${{ secrets.JIRA_BASE_URL }} + JIRA_USER_EMAIL: ${{ secrets.JIRA_USER_EMAIL }} + JIRA_API_TOKEN: ${{ secrets.JIRA_TOKEN }} + JIRA_EPIC_KEY: ${{ secrets.JIRA_EPIC_KEY }} + JIRA_PROJECT: ${{ secrets.JIRA_PROJECT }} + + - name: Create + id: create + uses: atlassian/gajira-create@master + with: + project: ${{ secrets.JIRA_PROJECT }} + issuetype: Bug + summary: | + [${{ github.event.repository.name }}] (${{ github.event.issue.number }}): ${{ github.event.issue.title }} + description: | + Github Link: ${{ github.event.issue.html_url }} + ${{ github.event.issue.body }} + fields: '{"parent": {"key": "${{ secrets.JIRA_EPIC_KEY }}"}}' + + - name: Log created issue + run: echo "Issue ${{ steps.create.outputs.issue }} was created" \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..b42b515 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,56 @@ +name: Release + +on: + push: + tags: + - '*.*.*' + - +permissions: write-all + +jobs: + release: + runs-on: ubuntu-latest + environment: deployment + steps: + - uses: actions/checkout@v2 + - name: Get tag + id: vars + run: echo ::set-output name=tag::${GITHUB_REF#refs/*/} + - name: Verify tag + run: | + VERSION=$(grep VERSION_NAME gradle.properties | awk -F= '{ print $2 }' | sed "s/-SNAPSHOT//") + if [ "${{ steps.vars.outputs.tag }}" != "$VERSION" ]; then { + echo "Tag ${{ steps.vars.outputs.tag }} does not match the package version ($VERSION)" + exit 1 + } fi + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + - name: cache gradle dependencies + uses: actions/cache@v2 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-core-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle-core- + - name: Publush release to sonatype + run: ./gradlew clean build publishToSonatype -Prelease closeAndReleaseSonatypeStagingRepository + env: + SIGNING_KEY_ID: ${{ secrets.SIGNING_KEY_ID }} + SIGNING_KEY_PASSWORD: ${{ secrets.SIGNING_KEY_PASSWORD }} + ORG_GRADLE_PROJECT_sonatypeUsername: ${{ secrets.NEXUS_USERNAME }} + ORG_GRADLE_PROJECT_sonatypePassword: ${{ secrets.NEXUS_PASSWORD }} + SIGNING_PRIVATE_KEY_BASE64: ${{ secrets.SIGNING_PRIVATE_KEY_BASE64 }} + + - name: create release + run: | + curl \ + -X POST \ + -H "Authorization: token $GITHUB_TOKEN" \ + https://api.github.com/repos/${{github.repository}}/releases \ + -d '{"tag_name": "${{ env.RELEASE_VERSION }}", "name": "${{ env.RELEASE_VERSION }}", "body": "Release of version ${{ env.RELEASE_VERSION }}", "draft": false, "prerelease": false, "generate_release_notes": true}' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_VERSION: ${{ steps.vars.outputs.tag }} \ No newline at end of file diff --git a/.github/workflows/snapshot.yml b/.github/workflows/snapshot.yml new file mode 100644 index 0000000..6ccd79a --- /dev/null +++ b/.github/workflows/snapshot.yml @@ -0,0 +1,31 @@ +name: Snapshot + +on: + push: + branches: [ main ] + +jobs: + snapshot: + runs-on: ubuntu-latest + environment: deployment + steps: + - uses: actions/checkout@v2 + - name: Grant execute permission for gradlew + run: chmod +x gradlew + - name: cache gradle dependencies + uses: actions/cache@v2 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-core-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle-core- + - name: Publush snapshot to sonatype + run: ./gradlew clean build publishToSonatype + env: + SIGNING_KEY_ID: ${{ secrets.SIGNING_KEY_ID }} + SIGNING_KEY_PASSWORD: ${{ secrets.SIGNING_KEY_PASSWORD }} + ORG_GRADLE_PROJECT_sonatypeUsername: ${{ secrets.NEXUS_USERNAME }} + ORG_GRADLE_PROJECT_sonatypePassword: ${{ secrets.NEXUS_PASSWORD }} + SIGNING_PRIVATE_KEY_BASE64: ${{ secrets.SIGNING_PRIVATE_KEY_BASE64 }} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..878c2cd --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +*.iml +.gradle +/local.properties +.idea +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..641b9d1 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,133 @@ + +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, 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 contributes to a positive environment for our +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 + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders 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, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our 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. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +[INSERT CONTACT METHOD]. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem 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 community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of 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. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +[https://www.contributor-covenant.org/version/2/0/code_of_conduct.html][v2.0]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available +at [https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.0]: https://www.contributor-covenant.org/version/2/0/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..8c3860f --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,16 @@ +# Contributing + +We want this community to be friendly and respectful to each other. Please follow it in all your interactions with the project. +All third party contributors acknowledge that any contributions they provide will be made under the same open source license that the open source project is provided under. + +### Sending a pull request + +> **Working on your first pull request?** You can learn how from this _free_ series: [How to Contribute to an Open Source Project on GitHub](https://egghead.io/series/how-to-contribute-to-an-open-source-project-on-github). + +When you're sending a pull request: + +- Prefer small pull requests focused on one change. +- Verify that linters and tests are passing. +- Review the documentation to make sure it looks good. +- Follow the pull request template when opening a pull request. +- For pull requests that change the API or implementation, discuss with maintainers first by opening an issue. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..73b54b7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,324 @@ +Twilio Software Development Kit License Agreement 2.0 + +Notice to user: THIS IS A TWILIO SOFTWARE DEVELOPMENT KIT LICENSE AGREEMENT 2.0 +BETWEEN YOU AND TWILIO FOR ACCESS TO AND USE OF TWILIO'S SOFTWARE DEVELOPMENT +KIT. BY USING THIS SOFTWARE DEVELOPMENT KIT, YOU ACCEPT ALL THE TERMS AND +CONDITIONS OF THIS TWILIO SOFTWARE DEVELOPMENT KIT LICENSE AGREEMENT 2.0. + +As a courtesy, below is a quick summary of how this Twilio Software Development +Kit License Agreement 2.0 applies when you download, use, or otherwise access +this Twilio Software Development Kit. The full version can be found by scrolling +down and is the only one that is legally controlling and binding. + +Summary + +When you download, use, or otherwise access this software development kit: + +* Twilio grants you a limited license to use this software development kit only + for the purposes of developing your own applications and software and + distributing those applications and software in connection with your use of + Twilio's products and services as a Twilio customer. + +* You agree not to use this software development kit to create a competing + product or service to this software development kit. + +* You agree that Twilio may make changes or updates to this software development + kit (including discontinuing support for and/or the availability of this + software development kit) at any time for any reason. + +* You agree that you will not use this software development kit in any way that + interferes with, disrupts, damages or otherwise affects anyone's servers, + networks, or services (including Twilio's). + +* You agree that any feedback you provide to Twilio regarding this software + development kit, including any suggestions for improvements, belong to Twilio + without any compensation to you and that Twilio owns all legal rights to that + feedback. + +* Except for this software development kit, any use of Twilio's products and + services is governed by a separate agreement between you and Twilio. + +Full Version + +1. Introduction + + a. This Twilio Software Development Kit License Agreement 2.0 (this + "Agreement") accompanies the Twilio Software Development Kit for the + software and related explanatory materials (including the Software and + Documentation, the "SDK") and includes any upgrades, modified versions, + updates, additions, and copies of the SDK licensed to you by Twilio. Twilio + and you may be referred to herein collectively as the "parties" or + individually as a "party." + + b. "Documentation" means Twilio's user manuals, handbooks, installation + guides, and any explanatory materials relating to or accompanying the SDK + provided by Twilio to you either electronically or in hard copy form. + + c. "Intellectual Property Rights" means any and all rights under patent law, + copyright law, trade secret law, trademark law, and any and all other + proprietary rights. + + d. "Software" means the object and source code, and/or other original works of + authorship in the SDK provided by Twilio to you, including all associated + example code, other tools and any upgrades, modified versions, updates, + additions, and copies provided to you pursuant to this Agreement. + + e. "Twilio" means Twilio Inc., organized under the laws of the State of + Delaware, USA, and operating under the laws of the USA with its principal + place of business at 101 Spear Street, 5th Floor, San Francisco, CA 94105. + +2. Accepting this Agreement + + a. In order to use the SDK, you must first agree to this Agreement. You may + not use the SDK if you do not accept this Agreement. By using the SDK, you + hereby agree to the terms of this Agreement. You may not use the SDK and + may not accept this Agreement if you are a person barred from receiving the + SDK under the laws of the United States or other countries, including the + country in which you are a resident or from which you use the SDK. If you + are agreeing to be bound by this Agreement on behalf of your employer or + other entity, you represent and warrant that you have full legal authority + to bind your employer or such entity to this Agreement. If you do not have + the requisite authority, you may not accept this Agreement or use the SDK + on behalf of your employer or other entity. You must use the SDK in + conjunction with Twilio's products and services ("Twilio Services"), and + your use of the Twilio Services will solely be governed by the Twilio Terms + of Service available at https://www.twilio.com/legal/tos or a separate + written agreement entered into between you and Twilio (each, a "Services + Agreement"). This Agreement will, in no way, modify or affect the terms of + the applicable Services Agreement. + +3. SDK License from Twilio + + a. Subject to the terms and conditions of this Agreement, Twilio grants you a + non-exclusive, non-sublicensable, non-assignable, non-transferable, + worldwide, royalty-free, access to and license to use the SDK solely (i) to + copy, display, perform, modify, and create derivative works from the SDK + only for the purpose of internal development of your software products + (each, an "Application") solely for an Application's use in conjunction + with the Twilio Services; and (ii) distribute the Software as part of an + Application, provided that you have a Services Agreement with Twilio for + the use of such Twilio Services. The license granted in Section 3(a)(ii) + will survive the termination of this Agreement, except to the extent you + are in material breach of any of the obligations hereunder. + + b. You are responsible and liable for all use of the SDK, directly or + indirectly, whether such access or use is permitted by or in violation of + this Agreement. Twilio has no liability to you or any third party arising + or resulting from your use of the SDK. + + c. Use, reproduction, and distribution of software dependencies and components + of the SDK licensed under an open source software license are governed + solely by the terms of that open source software license and not this + Agreement. You understand and acknowledge that such open source software is + not licensed to you pursuant to the provisions of this Agreement and that + this Agreement may not be construed to grant any such right and/or + license. If you do not agree to abide by the applicable terms for such + components of the SDK licensed under an open source software license, then + you should not use the SDK. + + d. You will not remove, obscure, or alter any proprietary rights notices + (including copyright and trademark notices) that may be affixed to or + contained within the SDK. + +4. Use of the SDK by You + + a. You may not use the SDK to develop a competing product or service to the + SDK or for any purposes not expressly permitted by this Agreement + including, but not limited to, using the SDK in any manner or for any + purpose that infringes, misappropriates, or otherwise violates any + Intellectual Property Right or other right of any person, or that violates + any applicable law or regulation (including any laws regarding the export + of data or software to and from the United States or other relevant + countries). + + b. You acknowledge and agree that Twilio has no obligation to provide + updates, upgrades, or any other modifications to the SDK. In addition, you + acknowledge and agree that Twilio has no obligation to support or maintain + the SDK. + + c. You agree that the form and nature of the SDK that Twilio provides may + change without prior notice to you and that future versions of the SDK may + be incompatible with Applications developed on previous versions of the + SDK. You agree that Twilio may stop (permanently or temporarily) providing + the SDK (or any features within the SDK) to you or to users generally at + Twilio's sole discretion, without prior notice to you. + + d. You agree that you will not engage in any activity with the SDK, + including the development or distribution of an Application, that + interferes with, disrupts, damages, or accesses in an unauthorized manner + the servers, networks, or other properties or services of any third party + including, but not limited to, Twilio or any telecommunications provider. + + e. Nothing in this Agreement gives you a right to use any of Twilio's trade + names, trademarks, service marks, logos, domain names, or other + distinctive brand features. + + f. You agree that you are solely responsible for (and that Twilio has no + responsibility to you or to any third party for) any breach of your + obligations under this Agreement, any applicable third party contract, or + any applicable law or regulation, and for the consequences (including any + loss or damage which Twilio or any third party may suffer) of any such + breach. + +5. Intellectual Property Ownership and Feedback + + a. You agree that Twilio or third parties own all legal right, title, and + interest in and to the SDK, including any Intellectual Property Rights + that subsist in the SDK. Twilio reserves all rights not expressly granted + to you in this Agreement. Except for the limited rights and licenses + expressly granted under this Agreement, nothing in this Agreement grants, + by implication, waiver, estoppel, or otherwise, to your or any third + party any Intellectual Property Rights or other right, title, or interest + in or to the SDK. + + b. Twilio agrees that it obtains no right, title or interest from you (or + your licensors) under this Agreement in or to any Applications that you + develop using the SDK, including any Intellectual Property Rights that + subsist in those Applications. + + c. If you or any of your employees or contractors send or transmit any + communications, materials, code, documentation, or other original works of + authorship (including any modifications) to Twilio by any form of + electronic or written communication, including but not limited to mail, + email, telephone, source code control systems, issue tracking systems, or + otherwise, suggesting or recommending changes to the SDK, including + without limitation, new features or functionality relating thereto, or any + comments, questions, suggestions, or the like ("Feedback"), Twilio is free + to use such Feedback irrespective of any other obligation or limitation + between the parties governing such Feedback. You hereby assign to Twilio + on your behalf, and on behalf of your employees, contractors and/or + agents, all right, title, and interest in, and Twilio is free to use, + Feedback without any attribution or compensation to any party, any ideas, + know-how, concepts, techniques, or other Intellectual Property Rights + contained in the Feedback, for any purpose whatsoever. Twilio is not + required to use any Feedback. For clarity, Feedback will not include any + code, documentation, or works of authorship, including the Intellectual + Property Rights embedded therein, which are (a) unrelated to the core + functionality of the Software or (b) conceived and/or developed by you (i) + prior to the date this Agreement is accepted by you, (ii) outside the + scope of this Agreement, or (iii) independently without the use of the + SDK. + +6. Terminating this Agreement + + a. This Agreement, as may be updated from time to time, will commence on the + date it is accepted by you and continue until terminated as set out below. + + b. If you want to terminate this Agreement, you may do so by ceasing your + use of the SDK. + + c. Without prejudice to any other rights, this Agreement shall terminate + automatically without notice from Twilio if: + i. your Services Agreement for use of the Twilio Services terminates; or + ii. you have breached any provision of this Agreement; or + iii. Twilio is required to do so by law or regulation; or + iv. Twilio decides to no longer provide the SDK or certain parts of the + SDK to users in the country in which you are resident or from which + you use the service, or the provision of the SDK or certain SDK + services to you by Twilio is, in Twilio's sole discretion, no longer + commercially viable. + + d. When this Agreement terminates, all of the legal rights, obligations, and + liabilities that you and Twilio have benefited from, been subject to (or + which have accrued over time whilst this Agreement has been in force) or + which are expressed to continue indefinitely, will be unaffected by this + cessation. + +7. DISCLAIMER OF WARRANTIES + + a. YOU EXPRESSLY UNDERSTAND AND AGREE THAT YOUR USE OF THE SDK IS AT YOUR + SOLE RISK AND THAT THE SDK IS PROVIDED "AS IS" AND "AS AVAILABLE" WITHOUT + WARRANTY OF ANY KIND FROM TWILIO. + + b. YOUR USE OF THE SDK AND ANY MATERIAL DOWNLOADED OR OTHERWISE OBTAINED + THROUGH THE USE OF THE SDK IS AT YOUR OWN DISCRETION AND RISK AND YOU ARE + SOLELY RESPONSIBLE FOR ANY DAMAGE TO YOUR COMPUTER SYSTEM OR OTHER DEVICE + OR LOSS OF DATA THAT RESULTS FROM SUCH USE. + + c. TWILIO FURTHER EXPRESSLY DISCLAIMS ALL WARRANTIES AND CONDITIONS OF ANY + KIND, WHETHER EXPRESS OR IMPLIED, INCLUDING, BUT NOT LIMITED TO THE + IMPLIED WARRANTIES AND CONDITIONS OF MERCHANTABILITY, FITNESS FOR A + PARTICULAR PURPOSE AND NON-INFRINGEMENT. + +8. LIMITATION OF LIABILITY + + a. YOU EXPRESSLY UNDERSTAND AND AGREE THAT TWILIO, ITS SUBSIDIARIES AND + AFFILIATES, AND ITS LICENSORS WILL NOT BE LIABLE TO YOU UNDER ANY THEORY + OF LIABILITY FOR ANY DAMAGES WHATSOEVER, INCLUDING, BUT NOT LIMITED TO, + DIRECT, INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, OR EXEMPLARY + DAMAGES THAT MAY BE INCURRED BY YOU, INCLUDING ANY LOSS OF DATA, WHETHER + OR NOT TWILIO OR ITS REPRESENTATIVES HAVE BEEN ADVISED OF OR SHOULD HAVE + BEEN AWARE OF THE POSSIBILITY OF ANY SUCH DAMAGES OR LOSSES ARISING. + +9. Indemnification + + a. To the maximum extent permitted by law, you agree to defend, indemnify + and hold harmless Twilio, its affiliates and their respective directors, + officers, employees, and agents from and against any and all claims, + actions, demands, suits, or proceedings, as well as any and all losses, + liabilities, damages, costs and expenses (including reasonable attorneys' + fees) arising out of or accruing from (a) your use of the SDK or any + resulting Application you develop on the SDK; (b) any Application you + develop using the SDK that infringes any copyright, trademark, trade + secret, trade dress, patent, or other intellectual property right of any + person or defames any person or violates their rights of publicity or + privacy; and (c) your non-compliance with the terms of this Agreement. + +10. Changes to this Agreement + + a. Twilio may make changes to this Agreement as it distributes new versions + of the SDK. When these changes are made, Twilio will make a new version + of this Agreement available on the website where the SDK is made + available. + +11. Additional Terms + + a. Except as provided in this Agreement, this Agreement supersedes all prior + and contemporaneous agreements, oral and written, in relation to the SDK. + No oral or written information or advice given by Twilio, its agents or + employees will create a warranty or in any way increase the scope of the + warranties or obligations under this Agreement. Except as permitted in + Section 10 of this Agreement, no modification to this Agreement will be + legally binding unless set forth in writing and signed by you and Twilio. + + b. You agree that Twilio's failure to enforce at any time any provision of + this Agreement or any other of your obligations does not waive Twilio's + right to do so later. And, if Twilio does expressly waive any provision + of this Agreement or any of your other obligations, that does not mean it + is waived for all time in the future. Any waiver must be in writing and + signed by you and Twilio to be legally binding. + + c. If any court of law, having the jurisdiction to decide on this matter, + rules that any provision of this Agreement is invalid, then that + provision will be removed from this Agreement without affecting the rest + of this Agreement. The remaining provisions of this Agreement will + continue to be valid and enforceable. + + d. You acknowledge and agree that each member of the group of companies of + which Twilio is the parent will be third party beneficiaries to this + Agreement and that such other companies will be entitled to directly + enforce, and rely upon, any provision of this Agreement that confers a + benefit on (or rights in favor of) them. Other than this, no other + person or company will be third party beneficiaries to this Agreement. + + e. THE SDK IS SUBJECT TO UNITED STATES EXPORT LAWS AND REGULATIONS. YOU + MUST COMPLY WITH ALL DOMESTIC AND INTERNATIONAL EXPORT LAWS AND + REGULATIONS THAT APPLY TO THE SDK. THESE LAWS INCLUDE RESTRICTIONS ON + DESTINATIONS, END USERS, AND END USE. + + f. You will not assign or otherwise transfer this Agreement, in whole or in + part, without Twilio's prior written consent. Any attempt by you to + assign, delegate, or transfer this Agreement will be void. Twilio may + assign this Agreement, in whole or in part, without consent. Subject to + this Section 13(f), this Agreement will be binding on both you and + Twilio and both parties' successors and assigns. + + g. This Agreement will be governed by and interpreted according to the laws + of the State of California without regard to conflicts of laws and + principles that would cause laws of another jurisdiction to apply. This + Agreement will not be governed by the United Nations Convention on + Contracts for the International Sale of Goods. Any legal suit, action or + proceeding arising out of or related to this Agreement or the SDK will be + instituted in either the state or federal courts of San Francisco, + California, and the parties each consent to the personal jurisdiction of + these courts. diff --git a/README.md b/README.md new file mode 100644 index 0000000..8957c47 --- /dev/null +++ b/README.md @@ -0,0 +1,363 @@ +# Analytics Live for Kotlin + +Analytics Live allows you to execute JavaScript stored in your workspace that +can create on-device Plugins that work just like regular on-device Plugins. + +![Analytics Live Plugin Diagram](imgs/analytics-kotlin-live-diagram.png) + +When you install the Analytics Live Plugin it can download a file containing +JavaScript instructions that are used to create one or more on-device Plugins. + +These plugins can then modify an event in the timeline like a regular on-device +plugin. + +## Getting Started + +Add the LivePlugins plugin to your Analytics instance + +```kotlin +import com.segment.analytics.liveplugins.kotlin.LivePlugins +import com.segment.analytics.kotlin.android.Analytics +... + +analytics = Analytics( + "WRITEKEY", + applicationContext + ) { + this.collectDeviceId = true + this.trackApplicationLifecycleEvents = true + this.trackDeepLinks = true + this.flushAt = 1 + this.flushInterval = 0 + } + + analytics.add(LivePlugins(fallbackFile: null)) +``` + +Note: A `fallbackFile` can be provided if you want a default file to be +available at first start up or no file is configured in your space. + +See the [LivePluginExample](Examples/LivePluginExample/) project for a example +on how to do this. + +For more on uploading the Analytics Live Plugin file see the [segmentcli](https://github.com/segment-integrations/segmentcli/) repo. + +## JavaScript API + +### Utility Functions +```javascript +/* +Log a message to the native console +*/ +console.log(message) +``` + +### Pre-defined Variables +```javascript +/* + This analytics pre-defined value represents the instance of the Analytics + class running in your native application. +*/ +let analytics = Analytics("") +``` + +### Exposed Native Classes +```javascript +// Represents the native-code Analytics class +class Analytics { + /* + Create a new instance of Analytics + Params: + writeKey: String - The writekey this instance will send data to + + to-do: See if other config options make sense to expose. + */ + constructor(writeKey) { } + + /* + Get the traits for the current identified user + Returns: + Object/Dictionary containing the users traits + */ + get traits() {} + + /* + Get the current user ID + */ + get userId() {} + + /* + Get the current anonymous ID + */ + get anonymousId() {} + + /* + Send a track event to segment + Params: + event: String - the name of the event + properties: Object - any additional properties or null + */ + track(event, properties) {} + + /* + Send an identify event to segment + Params: + userId: String - the user id + traits: Object - any additional information about the user + */ + identify(userId, traits) {} + + /* + Send a screen event to segment + Params: + title: String - The title of this screen + category: String - The category of this screen, or null + properties: Object - Any additional properties or null + */ + screen(title, category, properties) {} + + /* + Send a group event to segment + Params: + groupId: String - The groupId to associate with this session + traits: Object - Any additional information about this group or session + */ + group(groupId, traits) {} + + /* + Send an alias event to segment + Params: + newId: String - Associate this new ID with this user as an alias. + */ + alias(newId) {} + + /* + Resets the current session. A new anonymousId is created. All user identity, + traits, etc. are removed from this analytics instance. + */ + reset() {} + + /* + Tell this analytics instance to send any queued events off to segment. + */ + flush() {} + + /* + Add a LivePlugin subclass to this analytics instance. The LivePlugin subclass will + determine whether this LivePlugin should run as part of the source timeline, or + a specific destination timeline. + */ + add(livePlugin) {} +} + +// Represents an enum defining an live plugin type +const LivePluginType = { + before: "before", + enrichment: "enrichment", + after: "after", + utility: "utility" +}; + +// Represents the type of settings update being received +const UpdateType = { + initial: true, + refresh: false +} + +// Subclass LivePlugin to add custom behaviors/transformations +class LivePlugin { + /* + Create a LivePlugin + Params: + type: LivePluginType - The type of live plugin + destination: String - A string representing the destination key this + live plugin will be active for + */ + constructor(type, destination) {} + + /* + Returns the LivePluginType designation for this live plugin + */ + get type() {} + + /* + Returns the destination this live plugin is active on, or null + */ + get destination() {} + + /* + Update is called by the system when we receive new settings from Segment + or the Analytics system is starting up. + + Implementing this method is useful + for doing any setup that might be needed based on settings. + + Params: + settings: Object - A key/value object of all settings received from Segment + type: UpdateType - Determines if this is an initial batch of settings or a + subsequent update + */ + update(settings, type) {} + + /* + When events move through the system, `process` is called. This will naturally + call out to the appropriate handlers such as `track`, `identify`, etc. If you + want to perform transformations regardless of event type, override this method. + + Call `super.process()` if you'd like to preserve existing behavior of the base + class. + + Params: + event: Object - A key/value set of event data. + + Returns: + A transformed or modified event object, the original event object, + or null if the event is to be discarded. + */ + process(event) {} + + /* + Override this method to apply transformations specific to track events. + + Params: + event: Object - A key/value set of event data. + + Returns: + A transformed or modified event object, the original event object, + or null if the event is to be discarded. + */ + track(event) {} + + /* + Override this method to apply transformations specific to identify events. + + Params: + event: Object - A key/value set of event data. + + Returns: + A transformed or modified event object, the original event object, + or null if the event is to be discarded. + */ + identify(event) {} + + /* + Override this method to apply transformations specific to group events. + + Params: + event: Object - A key/value set of event data. + + Returns: + A transformed or modified event object, the original event object, + or null if the event is to be discarded. + */ + group(event) {} + + /* + Override this method to apply transformations specific to alias events. + + Params: + event: Object - A key/value set of event data. + + Returns: + A transformed or modified event object, the original event object, + or null if the event is to be discarded. + */ + alias(event) {} + + /* + Override this method to apply transformations specific to screen events. + + Params: + event: Object - A key/value set of event data. + + Returns: + A transformed or modified event object, the original event object, + or null if the event is to be discarded. + */ + screen(event) {} + + /* + Reset is called on an live plugin when the system is told to reset. Any + user information should be discarded. + */ + reset() {} + + /* + Flush is called on an live plugin when the system is told to send any + queued events off to Segment. + */ + flush() {} +} +``` + +### Creating and Using Live Plugins +```javascript +// This live plugin will fix an incorrectly named event property +class FixProductViewed extends LivePlugin { + track(event) { + if (event.event == "Product Viewed") { + // set the correct property to the value + event.properties.product_id = event.properties.product_did + // delete the misnamed property + delete event.properties.product_did + } + return event + } +} + +// create an instance of our FixProductViewed live plugin +let productViewFix = new FixProductViewed(LivePluginType.enrichment, null) + +// add it to the top level analytics instance. any track events that come +// through the system will now have event.properties.product_did renamed +// to product_id. +analytics.add(productViewFix) + + +// This live plugin will remove advertisingId from all events going to Amplitude +class RemoveAdvertisingId extends LivePlugin { + process(event) { + // delete the advertisingId + delete event.context.device.advertisingId + return super.process(event) + } +} + +// create an instance of our FixProductViewed live plugin +let deleteAdID = new RemoveAdvertisingId(LivePluginType.enrichment, "Amplitude") +// add it to the top level analytics instance. All events going to amplitude +// will now have the advertisingId property removed. +analytics.add(deleteAdID) + + +// This live plugin will reissue/convert a specific track event into a +// screen event. +class ConvertTrackToScreen extends LivePlugin { + track(event) { + // if the event name matches ... + if event.name == "Screen Viewed" { + // issue a screen event instead + analytics.screen(event.name) + } + // returning null to prevent the original event from + // moving forward. + return null + } +} + +// create an instance of our ConvertTrackToScreen live plugin +let convert = new ConvertTrackToScreen(LivePluginType.enrichment, null) +// add it to the top level analytics instance. Track events matching +// the specified event name will be converted to screen calls instead. +analytics.add(convert) +``` + +### Using the Analytics classes within Live Plugins runtime +```javascript +// Issue a simple track call into the system. This will +analytics.track("My Event") + +// Create a new instance of Analytics that points to a different write key +let myAnalytics = new Analytics("") +myAnalytics.track("New analytics instance started.") +``` diff --git a/analytics-kotlin-live/.gitignore b/analytics-kotlin-live/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/analytics-kotlin-live/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/analytics-kotlin-live/build.gradle b/analytics-kotlin-live/build.gradle new file mode 100644 index 0000000..431b735 --- /dev/null +++ b/analytics-kotlin-live/build.gradle @@ -0,0 +1,49 @@ +plugins { + id 'com.android.library' + id 'org.jetbrains.kotlin.android' + id 'org.jetbrains.kotlin.plugin.serialization' +} + +android { + compileSdk 34 + + defaultConfig { + minSdk 21 + targetSdk 34 + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles "consumer-rules.pro" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = '1.8' + } + namespace 'com.segment.analytics.liveplugins.kotlin' +} + +dependencies { + // Segment + implementation 'com.segment.analytics.kotlin:substrata:1.0.0' + implementation 'com.segment.analytics.kotlin:android:1.16.3' + + implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.1' + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.1' + implementation 'androidx.core:core-ktx:1.13.0' + implementation 'androidx.appcompat:appcompat:1.6.1' + implementation 'com.google.android.material:material:1.11.0' + testImplementation 'junit:junit:4.13.2' + androidTestImplementation 'androidx.test.ext:junit:1.1.5' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' +} + +apply from: rootProject.file('gradle/mvn-publish.gradle') \ No newline at end of file diff --git a/analytics-kotlin-live/consumer-rules.pro b/analytics-kotlin-live/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/analytics-kotlin-live/proguard-rules.pro b/analytics-kotlin-live/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/analytics-kotlin-live/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/analytics-kotlin-live/src/androidTest/java/com/segment/analytics/liveplugins/kotlin/ExampleInstrumentedTest.kt b/analytics-kotlin-live/src/androidTest/java/com/segment/analytics/liveplugins/kotlin/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..da1c760 --- /dev/null +++ b/analytics-kotlin-live/src/androidTest/java/com/segment/analytics/liveplugins/kotlin/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.segment.analytics.liveplugins.kotlin + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.segment.analytics.liveplugins.kotlin.test", appContext.packageName) + } +} \ No newline at end of file diff --git a/analytics-kotlin-live/src/main/AndroidManifest.xml b/analytics-kotlin-live/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a5918e6 --- /dev/null +++ b/analytics-kotlin-live/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/analytics-kotlin-live/src/main/java/com/segment/analytics/liveplugins/kotlin/Bundler.kt b/analytics-kotlin-live/src/main/java/com/segment/analytics/liveplugins/kotlin/Bundler.kt new file mode 100644 index 0000000..3c2b64a --- /dev/null +++ b/analytics-kotlin-live/src/main/java/com/segment/analytics/liveplugins/kotlin/Bundler.kt @@ -0,0 +1,30 @@ +package com.segment.analytics.liveplugins.kotlin + +import java.io.BufferedInputStream +import java.io.File +import java.io.FileOutputStream +import java.net.URL + + +fun disableBundleURL(file: File) { + val content = "// live plugins are disabled." + FileOutputStream(file, false).use { + it.write(content.toByteArray()) + } +} + +fun download(from: String, file: File) { + val url = URL(from) + url.openStream().use { inp -> + BufferedInputStream(inp).use { bis -> + FileOutputStream(file, false).use { fos -> + val len = 64 * 1024 + val data = ByteArray(len) + var count: Int + while (bis.read(data, 0, len).also { count = it } != -1) { + fos.write(data, 0, count) + } + } + } + } +} \ No newline at end of file diff --git a/analytics-kotlin-live/src/main/java/com/segment/analytics/liveplugins/kotlin/EmbeddedJS.kt b/analytics-kotlin-live/src/main/java/com/segment/analytics/liveplugins/kotlin/EmbeddedJS.kt new file mode 100644 index 0000000..1428ef8 --- /dev/null +++ b/analytics-kotlin-live/src/main/java/com/segment/analytics/liveplugins/kotlin/EmbeddedJS.kt @@ -0,0 +1,66 @@ +package com.segment.analytics.liveplugins.kotlin + +import com.segment.analytics.kotlin.core.platform.Plugin + +// TODO do we want utility?? +object EmbeddedJS { + val ENUM_SETUP_SCRIPT = """ + const LivePluginType = { + before: ${Plugin.Type.Before.toInt()}, + enrichment: ${Plugin.Type.Enrichment.toInt()}, + after: ${Plugin.Type.After.toInt()}, +// utility: ${Plugin.Type.Utility.toInt()} + }; + """.trimIndent() + + val LIVE_PLUGINS_BASE_SETUP_SCRIPT = """ + class LivePlugin { + constructor(type, destination) { + console.log("js: LivePlugin.constructor() called"); + this.type = type; + this.destination = destination; + } + update(settings, type) { } + execute(event) { + console.log("js: LivePlugin.execute() called"); + var result = event; + switch(event.type) { + case "identify": + result = this.identify(event); + case "track": + result = this.track(event); + case "group": + result = this.group(event); + case "alias": + result = this.alias(event); + case "screen": + result = this.screen(event); + } + return result; + } + identify(event) { return event; } + track(event) { return event; } + group(event) { return event; } + alias(event) { return event; } + screen(event) { return event; } + reset() { } + flush() { } + } + """.trimIndent() +} + +fun Plugin.Type.toInt() = when(this) { + Plugin.Type.Before -> 0 + Plugin.Type.Enrichment -> 1 + Plugin.Type.After -> 2 +// Plugin.Type.Utility -> 3 + else -> -1 +} + +fun pluginTypeFromInt(value: Int) = when (value) { + 0 -> Plugin.Type.Before + 1 -> Plugin.Type.Enrichment + 2 -> Plugin.Type.After +// 3 -> Plugin.Type.Utility + else -> null +} \ No newline at end of file diff --git a/analytics-kotlin-live/src/main/java/com/segment/analytics/liveplugins/kotlin/JSAnalytics.kt b/analytics-kotlin-live/src/main/java/com/segment/analytics/liveplugins/kotlin/JSAnalytics.kt new file mode 100644 index 0000000..7cda024 --- /dev/null +++ b/analytics-kotlin-live/src/main/java/com/segment/analytics/liveplugins/kotlin/JSAnalytics.kt @@ -0,0 +1,157 @@ +package com.segment.analytics.liveplugins.kotlin + +import android.content.Context +import com.segment.analytics.kotlin.android.Analytics +import com.segment.analytics.kotlin.core.Analytics +import com.segment.analytics.kotlin.core.BaseEvent +import com.segment.analytics.kotlin.core.platform.Plugin +import com.segment.analytics.kotlin.core.utilities.putInContext +import com.segment.analytics.substrata.kotlin.JSObject +import com.segment.analytics.substrata.kotlin.JSScope +import com.segment.analytics.substrata.kotlin.JsonElementConverter +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import java.lang.ref.WeakReference + +object LivePluginsHolder { + var plugin: WeakReference? = null +} + +class JSAnalytics { + + internal lateinit var analytics: Analytics + internal lateinit var engine: JSScope + internal var mainAnalytics: Boolean = false + + val anonymousId: String + get() = analytics.anonymousId() + + val userId: String? + get() = analytics.userId() + + val traits: JSObject? + get() = analytics.traits()?.let { + engine.await { + JsonElementConverter.write(it, context) as JSObject + } + } + + val context: Any? + get() = analytics.configuration.application + + // JSEngine requires an empty constructor to be able to export this class + constructor() {} + + // This is the constructor used by the native to create the injected instance + constructor(analytics: Analytics, engine: JSScope) { + this.analytics = analytics + this.engine = engine + mainAnalytics = true + } + + // This is the constructor used when JS creates a new one + constructor(writeKey: String) { + val application = LivePluginsHolder.plugin?.get()?.analytics?.configuration?.application + val engine = LivePluginsHolder.plugin?.get()?.engine + require(application != null) { + "Application Context Not Found!" + } + require(engine != null) { + "JS Engine is not initialized!" + } + require(application is Context) { + "Incompatible Android Context!" + } + this.analytics = Analytics(writeKey, application) + this.engine = engine + mainAnalytics = false + } + + fun track(event: String) { + analytics.track(event) { + it?.insertEventOrigin() + } + } + + fun track(event: String, properties: JSObject) { + analytics.track(event, JsonElementConverter.read(properties)) { + it?.insertEventOrigin() + } + } + + fun identify(userId: String) { + analytics.identify(userId) { + it?.insertEventOrigin() + } + } + + fun identify(userId: String, traits: JSObject) { + analytics.identify(userId, JsonElementConverter.read(traits)) { + it?.insertEventOrigin() + } + } + + fun screen(title: String, category: String) { + analytics.screen(title, category) { + it?.insertEventOrigin() + } + } + + fun screen(title: String, category: String, properties: JSObject) { + analytics.screen(title, JsonElementConverter.read(properties), category) { + it?.insertEventOrigin() + } + } + + fun group(groupId: String) { + analytics.group(groupId) { + it?.insertEventOrigin() + } + } + + fun group(groupId: String, traits: JSObject) { + analytics.group(groupId, JsonElementConverter.read(traits)) { + it?.insertEventOrigin() + } + } + + fun alias(newId: String) { + analytics.alias(newId) { + it?.insertEventOrigin() + } + } + + fun flush() { + analytics.flush() + } + + fun reset() { + analytics.reset() + } + + fun add(plugin: JSObject): Boolean { + if (!mainAnalytics) return false // Only allow adding plugins to injected analytics + + val type: Plugin.Type = pluginTypeFromInt(plugin.getInt("type")) ?: return false + var result = false + val livePlugin = LivePlugin(plugin, type, engine) + val destination = plugin["destination"] + if (destination is String) { + analytics.find(destination)?.let { + it.add(livePlugin) + result = true + } + } else { + // Add it to the main timeline + analytics.add(livePlugin) + result = true + } + return result + } + + private fun BaseEvent.insertEventOrigin() : BaseEvent { + return putInContext("__eventOrigin", buildJsonObject { + put("type", "js") + }) + } +} \ No newline at end of file diff --git a/analytics-kotlin-live/src/main/java/com/segment/analytics/liveplugins/kotlin/LivePlugin.kt b/analytics-kotlin-live/src/main/java/com/segment/analytics/liveplugins/kotlin/LivePlugin.kt new file mode 100644 index 0000000..5637671 --- /dev/null +++ b/analytics-kotlin-live/src/main/java/com/segment/analytics/liveplugins/kotlin/LivePlugin.kt @@ -0,0 +1,84 @@ +package com.segment.analytics.liveplugins.kotlin + +import com.segment.analytics.kotlin.core.AliasEvent +import com.segment.analytics.kotlin.core.Analytics +import com.segment.analytics.kotlin.core.BaseEvent +import com.segment.analytics.kotlin.core.GroupEvent +import com.segment.analytics.kotlin.core.IdentifyEvent +import com.segment.analytics.kotlin.core.ScreenEvent +import com.segment.analytics.kotlin.core.Settings +import com.segment.analytics.kotlin.core.TrackEvent +import com.segment.analytics.kotlin.core.platform.EventPlugin +import com.segment.analytics.kotlin.core.platform.Plugin +import com.segment.analytics.kotlin.core.utilities.EncodeDefaultsJson +import com.segment.analytics.kotlin.core.utilities.toBaseEvent +import com.segment.analytics.substrata.kotlin.JSObject +import com.segment.analytics.substrata.kotlin.JSScope +import com.segment.analytics.substrata.kotlin.JsonElementConverter +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.jsonObject + +/** + * LivePlugin is the native Analytics Plugin representation of the jsPlugin specified + * in the LivePlugins bundle. + * LivePlugin is responsible for ensuring all data being passed is understandable by JS + */ +internal class LivePlugin( + private val jsPlugin: JSObject, + override val type: Plugin.Type, + private val engine: JSScope +) : EventPlugin { + + override lateinit var analytics: Analytics + + override fun update(settings: Settings, type: Plugin.UpdateType) { + super.update(settings, type) + + val settingsJson = Json.encodeToJsonElement(settings).jsonObject + engine.sync { + call(jsPlugin, + "update", + JsonElementConverter.write(settingsJson, context), + type.toString()) + } + } + + override fun alias(payload: AliasEvent): BaseEvent? { + return execute(payload) + } + + override fun group(payload: GroupEvent): BaseEvent? { + return execute(payload) + } + + override fun identify(payload: IdentifyEvent): BaseEvent? { + return execute(payload) + } + + override fun screen(payload: ScreenEvent): BaseEvent? { + return execute(payload) + } + + override fun track(payload: TrackEvent): BaseEvent? { + return execute(payload) + } + + override fun execute(event: BaseEvent): BaseEvent? { + val payload = EncodeDefaultsJson.encodeToJsonElement(event) + val ret = engine.await { + val modified = call( + jsPlugin, + "execute", + JsonElementConverter.write(payload, context) + ) + + return@await if (modified is JSObject) { + JsonElementConverter.read(modified).jsonObject.toBaseEvent() + } else { + null + } + } + return ret + } +} \ No newline at end of file diff --git a/analytics-kotlin-live/src/main/java/com/segment/analytics/liveplugins/kotlin/LivePlugins.kt b/analytics-kotlin-live/src/main/java/com/segment/analytics/liveplugins/kotlin/LivePlugins.kt new file mode 100644 index 0000000..0375760 --- /dev/null +++ b/analytics-kotlin-live/src/main/java/com/segment/analytics/liveplugins/kotlin/LivePlugins.kt @@ -0,0 +1,188 @@ +package com.segment.analytics.liveplugins.kotlin + +import android.content.Context +import android.content.SharedPreferences +import com.segment.analytics.kotlin.core.Analytics +import com.segment.analytics.kotlin.core.Settings +import com.segment.analytics.kotlin.core.emptyJsonObject +import com.segment.analytics.kotlin.core.platform.EventPlugin +import com.segment.analytics.kotlin.core.platform.Plugin +import com.segment.analytics.kotlin.core.platform.plugins.logger.log +import com.segment.analytics.kotlin.core.utilities.LenientJson +import com.segment.analytics.substrata.kotlin.JSScope +import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.io.File +import java.io.FileOutputStream +import java.io.InputStream +import java.lang.ref.WeakReference +import java.util.concurrent.CopyOnWriteArrayList +import kotlin.coroutines.CoroutineContext + +interface LivePluginsDependent { + fun prepare(engine: JSScope) + fun readyToStart() +} + +@Serializable +data class LivePluginsSettings( + val version: Int = -1, + val downloadURL: String = "" +) + +class LivePlugins( + private val fallbackFile: InputStream? = null, + private val forceFallbackFile: Boolean = false +) : EventPlugin { + override val type: Plugin.Type = Plugin.Type.Utility + + companion object { + const val LIVE_PLUGINS_FILE_NAME = "livePlugins.js" + const val SHARED_PREFS_KEY = "LivePlugins" + var loaded = false + } + override lateinit var analytics: Analytics + + private lateinit var sharedPreferences: SharedPreferences + + val engine = JSScope { + it.printStackTrace() + } + + private lateinit var livePluginFile: File + + private val dependents = CopyOnWriteArrayList() + + // Call this function when app is destroyed, to prevent memory leaks + fun release() { + engine.release() + } + + override fun setup(analytics: Analytics) { + super.setup(analytics) + LivePluginsHolder.plugin = WeakReference(this) + + // if we've already got LivePlugins, we don't wanna do any setup + if (analytics.find(LivePlugins::class) != null) { + // we can't remove ourselves here because configure needs to be + // called before update; so we can only remove ourselves in update. + return + } + + + require(analytics.configuration.application is Context) { + "Incompatible Android Context!" + } + val context = analytics.configuration.application as Context + sharedPreferences = context.getSharedPreferences( + "analytics-liveplugins-${analytics.configuration.writeKey}", + Context.MODE_PRIVATE + ) + val storageDirectory = context.getDir("segment-data", Context.MODE_PRIVATE) + livePluginFile = File(storageDirectory, LIVE_PLUGINS_FILE_NAME) + + configureEngine() + } + + override fun update(settings: Settings, type: Plugin.UpdateType) { + // if we find an existing LivePlugins instance that is not ourselves... + if (analytics.find(LivePlugins::class) != this) { + // remove ourselves. we can't do this in configure. + analytics.remove(this) + return + } + + if (loaded) { + return + } + + loaded = true + + if (settings.edgeFunction != emptyJsonObject) { + val livePluginsData = LenientJson.decodeFromJsonElement( + LivePluginsSettings.serializer(), + settings.edgeFunction + ) + setLivePluginData(livePluginsData) + } + loadLivePlugin(livePluginFile) + } + + fun addDependent(plugin: LivePluginsDependent) { + dependents.add(plugin) + // this plugin already loaded, notify the dependents right away + if (loaded) { + plugin.prepare(engine) + plugin.readyToStart() + } + } + + private fun configureEngine() = engine.sync { + val jsAnalytics = JSAnalytics(analytics, engine) + export(jsAnalytics, "Analytics","analytics") + + evaluate(EmbeddedJS.ENUM_SETUP_SCRIPT) + evaluate(EmbeddedJS.LIVE_PLUGINS_BASE_SETUP_SCRIPT) + } + + private fun loadLivePlugin(file: File) { + if (fallbackFile != null && (forceFallbackFile || !file.exists())) { + // Forced to use fallback file + fallbackFile.copyTo(FileOutputStream(file)) + } + + for (d in dependents) { + d.prepare(engine) + } + engine.launch (global = true) { + loadBundle(file.inputStream()) { error -> + if (error != null) { + analytics.log(error.message ?: "") + } else { + for (d in dependents) { + d.readyToStart() + } + } + } + } + } + + private fun setLivePluginData(data: LivePluginsSettings) { + currentData().let { currData -> + val newVersion = data.version + val currVersion = currData.version + + if (newVersion > currVersion) { + updateLivePluginsConfig(data) + } + } ?: updateLivePluginsConfig(data) + } + + private fun currentData(): LivePluginsSettings { + var currentData = LivePluginsSettings() // Default to an "empty" settings with version -1 + val dataString = sharedPreferences.getString(SHARED_PREFS_KEY, null) + if (dataString != null) { + currentData = Json.decodeFromString(dataString) + } + return currentData + } + + private fun updateLivePluginsConfig(data: LivePluginsSettings) { + val urlString = data.downloadURL + + sharedPreferences.edit().putString(SHARED_PREFS_KEY, Json.encodeToString(data)).apply() + + with(analytics) { + analyticsScope.launch(fileIODispatcher as CoroutineContext) { + if (urlString.isNotEmpty()) { + download(urlString, livePluginFile) + log("New LivePlugins installed. Will be used on next app launch.") + } else { + disableBundleURL(livePluginFile) + } + } + } + } +} \ No newline at end of file diff --git a/analytics-kotlin-live/src/test/java/com/segment/analytics/liveplugins/kotlin/ExampleUnitTest.kt b/analytics-kotlin-live/src/test/java/com/segment/analytics/liveplugins/kotlin/ExampleUnitTest.kt new file mode 100644 index 0000000..0302ea1 --- /dev/null +++ b/analytics-kotlin-live/src/test/java/com/segment/analytics/liveplugins/kotlin/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package com.segment.analytics.liveplugins.kotlin + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} \ No newline at end of file diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..8036a6a --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,47 @@ +plugins { + id 'com.android.application' + id 'org.jetbrains.kotlin.android' +} + +android { + compileSdk 34 + + defaultConfig { + multiDexEnabled true + applicationId "com.segment.analytics.liveplugins.app" + minSdk 21 + targetSdk 34 + versionCode 1 + versionName "1.0" + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = '1.8' + } + namespace 'com.segment.analytics.liveplugins.app' +} + +dependencies { + implementation project(':analytics-kotlin-live') + implementation 'com.segment.analytics.kotlin:substrata:1.0.0' + implementation 'com.segment.analytics.kotlin:android:1.16.0' + implementation 'androidx.core:core-ktx:1.13.0' + implementation 'androidx.appcompat:appcompat:1.6.1' + implementation 'com.google.android.material:material:1.11.0' + implementation 'androidx.constraintlayout:constraintlayout:2.1.4' + testImplementation 'junit:junit:4.13.2' + androidTestImplementation 'androidx.test.ext:junit:1.1.5' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/src/androidTest/java/com/segment/analytics/liveplugins/app/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/segment/analytics/liveplugins/app/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..8615359 --- /dev/null +++ b/app/src/androidTest/java/com/segment/analytics/liveplugins/app/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.segment.analytics.liveplugins.app + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.segment.analytics.liveplugins.app", appContext.packageName) + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..ab99c5b --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/segment/analytics/liveplugins/app/MainActivity.kt b/app/src/main/java/com/segment/analytics/liveplugins/app/MainActivity.kt new file mode 100644 index 0000000..39eb890 --- /dev/null +++ b/app/src/main/java/com/segment/analytics/liveplugins/app/MainActivity.kt @@ -0,0 +1,31 @@ +package com.segment.analytics.liveplugins.app + +import androidx.appcompat.app.AppCompatActivity +import android.os.Bundle +import android.widget.TextView + + + +class MainActivity : AppCompatActivity() { + val analytics = MainApplication.analytics + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_main) + findViewById(R.id.btn_checkout).setOnClickListener { + analytics.track("User Checkout") + } + + findViewById(R.id.btn_exit).setOnClickListener { + analytics.track("Exit Clicked") + } + + findViewById(R.id.btn_purchase).setOnClickListener { + analytics.track("Item Purchased") + } + + findViewById(R.id.btn_register).setOnClickListener { + analytics.track("User Registered") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/segment/analytics/liveplugins/app/MainApplication.kt b/app/src/main/java/com/segment/analytics/liveplugins/app/MainApplication.kt new file mode 100644 index 0000000..fccfbe2 --- /dev/null +++ b/app/src/main/java/com/segment/analytics/liveplugins/app/MainApplication.kt @@ -0,0 +1,56 @@ +package com.segment.analytics.liveplugins.app + +import android.app.Application +import com.segment.analytics.kotlin.android.Analytics +import com.segment.analytics.kotlin.core.Analytics +import com.segment.analytics.kotlin.core.platform.Plugin +import com.segment.analytics.liveplugins.app.filters.WebhookPlugin +import com.segment.analytics.liveplugins.kotlin.LivePlugins +import com.segment.analytics.liveplugins.kotlin.LivePluginsDependent +import com.segment.analytics.substrata.kotlin.JSScope +import java.util.concurrent.Executors + +class MainApplication : Application() { + companion object { + lateinit var analytics: Analytics + } + + override fun onCreate() { + super.onCreate() + analytics = Analytics( + "KZrBizvzx7GOj3Ek9Gin7GPsYhezAzw6", + applicationContext + ) { + this.collectDeviceId = true + this.trackApplicationLifecycleEvents = true + this.trackDeepLinks = true + this.flushAt = 1 + this.flushInterval = 0 + } + +// analytics.add(WebhookPlugin("https://webhook.site/5fefa55b-b5cf-4bd5-abe6-9234a003baa8", Executors.newSingleThreadExecutor())) + + val backup = resources.openRawResource(R.raw.default_liveplugins) + val livePlugins = LivePlugins(backup, true) + analytics.add(livePlugins) + analytics.add(Signals) + } + + object Signals: Plugin, LivePluginsDependent { + override lateinit var analytics: Analytics + override val type: Plugin.Type = Plugin.Type.After + override fun setup(analytics: Analytics) { + super.setup(analytics) + + analytics.find(LivePlugins::class)?.addDependent(this) + } + override fun prepare(engine: JSScope) { + println("prepare called") + } + + override fun readyToStart() { + println("readyToStart called") + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/com/segment/analytics/liveplugins/app/filters/WebhookDestination.kt b/app/src/main/java/com/segment/analytics/liveplugins/app/filters/WebhookDestination.kt new file mode 100644 index 0000000..d827f58 --- /dev/null +++ b/app/src/main/java/com/segment/analytics/liveplugins/app/filters/WebhookDestination.kt @@ -0,0 +1,106 @@ +package com.segment.analytics.liveplugins.app.filters + +import com.segment.analytics.kotlin.core.* +import com.segment.analytics.kotlin.core.platform.DestinationPlugin +import com.segment.analytics.kotlin.core.platform.Plugin +import com.segment.analytics.kotlin.core.platform.plugins.logger.* +import com.segment.analytics.kotlin.core.utilities.EncodeDefaultsJson +import kotlinx.serialization.encodeToString +import java.io.DataOutputStream +import java.io.IOException +import java.io.InputStream +import java.net.HttpURLConnection +import java.net.MalformedURLException +import java.net.URL +import java.util.concurrent.ExecutorService + +// An After plugin that doesn't modify the incoming payload, and sends it to the desired webhook +class WebhookPlugin( + private val webhookUrl: String, + private val networkExecutor: ExecutorService +) : DestinationPlugin() { + override val key: String = "Webhooks" + override lateinit var analytics: Analytics + + fun log(message: String) { + println("PRAY: $message") + } + + /** + * Sends a JSON payload to the specified webhookUrl, with the Content-Type=application/json + * header set + */ + private inline fun sendPayloadToWebhook(payload: T?) = + networkExecutor.submit { + payload?.let { + log(message = "Running ${payload.type} payload through $key") + val requestedURL: URL = try { + URL(webhookUrl) + } catch (e: MalformedURLException) { + throw IOException("Attempted to use malformed url: $webhookUrl", e) + } + + val connection = requestedURL.openConnection() as HttpURLConnection + connection.doOutput = true + connection.setChunkedStreamingMode(0) + connection.setRequestProperty("Content-Type", "application/json") + + val outputStream = DataOutputStream(connection.outputStream) + val payloadJson = EncodeDefaultsJson.encodeToString(payload) + outputStream.writeBytes(payloadJson) + + outputStream.use { + val responseCode = connection.responseCode + if (responseCode >= 300) { + var responseBody: String? + var inputStream: InputStream? = null + try { + inputStream = try { + connection.inputStream + } catch (ignored: IOException) { + connection.errorStream + } + responseBody = + inputStream?.bufferedReader()?.use(java.io.BufferedReader::readText) + ?: "" + } catch (e: IOException) { + responseBody = ( + "Could not read response body for rejected message: " + + e.toString() + ) + } finally { + inputStream?.close() + } + log( + message = "Failed to send payload, statusCode=$responseCode, body=$responseBody" + ) + } + } + } + } + + override fun track(payload: TrackEvent): BaseEvent? { + sendPayloadToWebhook(payload) + return payload + } + + override fun identify(payload: IdentifyEvent): BaseEvent? { + sendPayloadToWebhook(payload) + return payload + } + + override fun screen(payload: ScreenEvent): BaseEvent? { + sendPayloadToWebhook(payload) + return payload + } + + override fun group(payload: GroupEvent): BaseEvent? { + sendPayloadToWebhook(payload) + return payload + } + + override fun alias(payload: AliasEvent): BaseEvent? { + sendPayloadToWebhook(payload) + return payload + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..bd23887 --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,40 @@ + + + + + +