├── .circleci └── config.yml ├── .github ├── CODEOWNERS ├── dependabot.yml └── workflows │ ├── codeql.yml │ └── generate-javadoc-site.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── SECURITY.md ├── build.gradle ├── example ├── .gitignore ├── build.gradle ├── proguard-project.txt ├── project.properties └── src │ └── main │ ├── AndroidManifest.xml │ ├── java │ └── com │ │ └── treasuredata │ │ └── android │ │ └── demo │ │ ├── DemoApp.java │ │ └── MainActivity.java │ └── res │ ├── drawable-hdpi │ └── ic_launcher.png │ ├── drawable-mdpi │ └── ic_launcher.png │ ├── drawable-xhdpi │ ├── banner.png │ ├── ic_launcher.png │ └── wine.jpg │ ├── drawable-xxhdpi │ └── ic_launcher.png │ ├── layout │ └── activity_main.xml │ └── values │ ├── dimens.xml │ ├── strings.xml │ └── styles.xml ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── settings.gradle ├── src ├── main │ └── java │ │ └── com │ │ └── treasuredata │ │ └── android │ │ ├── CustomizedJSON.java │ │ ├── Debouncer.java │ │ ├── GetAdvertisingIdAsyncTask.java │ │ ├── GetAdvertisingIdAsyncTaskCallback.java │ │ ├── Session.java │ │ ├── TDCallback.java │ │ ├── TDClient.java │ │ ├── TDClientBuilder.java │ │ ├── TDEventStore.java │ │ ├── TDHttpHandler.java │ │ ├── TDJsonHandler.java │ │ ├── TDLogging.java │ │ ├── TreasureData.java │ │ ├── billing │ │ └── internal │ │ │ ├── BillingDelegate.java │ │ │ ├── Purchase.java │ │ │ ├── PurchaseConstants.java │ │ │ ├── PurchaseEventActivityLifecycleTracker.java │ │ │ └── PurchaseEventManager.java │ │ └── cdp │ │ ├── CDPAPIException.java │ │ ├── CDPClient.java │ │ ├── CDPClientImpl.java │ │ ├── ErrorFetchUserSegmentsResult.java │ │ ├── FetchUserSegmentsCallback.java │ │ ├── FetchUserSegmentsResult.java │ │ ├── JSONFetchUserSegmentsResult.java │ │ ├── Profile.java │ │ └── ProfileImpl.java └── test │ └── java │ └── com │ └── treasuredata │ └── android │ ├── SessionTest.java │ ├── TDClientTest.java │ ├── TDJsonHandlerTest.java │ ├── TDLoggingTest.java │ ├── TreasureDataTest.java │ └── cdp │ ├── CDPAPIExceptionTest.java │ ├── FetchUserSegmentsResultTest.java │ └── ProfileImplTest.java └── test-host ├── .gitignore ├── README.md ├── build.gradle ├── proguard-rules.pro └── src ├── androidTest └── java │ └── com │ └── treasuredata │ └── android │ ├── GetAdvertisingAsyncTaskTest.java │ ├── TreasureDataInstrumentTest.java │ └── cdp │ └── CDPClientImplTest.java └── main ├── AndroidManifest.xml └── res ├── drawable-v24 └── ic_launcher_foreground.xml ├── drawable └── ic_launcher_background.xml ├── mipmap-anydpi-v26 ├── ic_launcher.xml └── ic_launcher_round.xml ├── mipmap-hdpi ├── ic_launcher.png └── ic_launcher_round.png ├── mipmap-mdpi ├── ic_launcher.png └── ic_launcher_round.png ├── mipmap-xhdpi ├── ic_launcher.png └── ic_launcher_round.png ├── mipmap-xxhdpi ├── ic_launcher.png └── ic_launcher_round.png ├── mipmap-xxxhdpi ├── ic_launcher.png └── ic_launcher_round.png └── values ├── colors.xml ├── strings.xml └── styles.xml /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Very minimal setup, just for the sake of automated testing 2 | 3 | version: 2.1 4 | 5 | orbs: 6 | android: circleci/android@2.5.0 7 | 8 | _defaults: 9 | - &restore_gradle_cache 10 | restore_cache: 11 | name: Restore Gradle Cache 12 | key: jars-{{ checksum "build.gradle" }} 13 | - &download_dependencies 14 | run: 15 | name: Download Dependencies 16 | command: ./gradlew androidDependencies 17 | - &save_gradle_cache 18 | save_cache: 19 | paths: 20 | - ~/.gradle 21 | key: jars-{{ checksum "build.gradle" }} 22 | - &run_tests 23 | run: 24 | name: Run Tests 25 | command: ./gradlew test 26 | - &run_connected_android_tests 27 | run: 28 | name: Run Connected Android Tests 29 | command: ./gradlew connectedAndroidTest 30 | - &compress_report 31 | run: 32 | name: Compress Report 33 | command: tar -cvf report.tar -C ./build/reports . 34 | - &store_report 35 | store_artifacts: 36 | path: report.tar 37 | destination: report.tar 38 | - &store_test_results 39 | store_test_results: 40 | path: build/test-results 41 | 42 | 43 | commands: 44 | store_instrument_results: 45 | description: "Store instrument test results" 46 | steps: 47 | - run: 48 | name: Save test results 49 | command: | 50 | mkdir -p ~/test-results/junit/ 51 | find . -type f -regex ".*/build/outputs/androidTest-results/.*xml" -exec cp {} ~/test-results/junit/ \; 52 | when: always 53 | - store_test_results: 54 | path: ~/test-results 55 | - run: 56 | name: Compress Instrument Test Report 57 | command: tar -cvf instrument-report.tar -C ./test-host/build/reports/androidTests/connected . 58 | - store_artifacts: 59 | path: instrument-report.tar 60 | destination: instrument-report.tar 61 | - run: 62 | name: Compress test-result.pb 63 | command: tar -cvf pb-report.tar -C test-host/build/outputs/androidTest-results/connected . 64 | - store_artifacts: 65 | path: pb-report.tar 66 | destination: pb-report.tar 67 | 68 | run_instrument_tests: 69 | description: "Run instrument tests" 70 | parameters: 71 | api_level: 72 | type: "string" 73 | steps: 74 | - checkout 75 | - android/start-emulator-and-run-tests: 76 | system-image: system-images;android-<>;google_apis;x86_64 77 | - store_instrument_results 78 | 79 | run_local_tests: 80 | description: "Run unit tests" 81 | steps: 82 | - checkout 83 | - *restore_gradle_cache 84 | - *download_dependencies 85 | - *save_gradle_cache 86 | - *run_tests 87 | - *compress_report 88 | - *store_report 89 | - *store_test_results 90 | 91 | 92 | jobs: 93 | local_tests: 94 | docker: 95 | - image: cimg/android:2024.04.1 96 | steps: 97 | - run_local_tests 98 | 99 | instrument_tests_level_34: 100 | executor: 101 | name: android/android-machine 102 | resource-class: large 103 | tag: "2024.04.1" 104 | steps: 105 | - run_instrument_tests: 106 | api_level: '34' 107 | 108 | instrument_tests_level_33: 109 | executor: 110 | name: android/android-machine 111 | resource-class: large 112 | tag: "2024.04.1" 113 | steps: 114 | - run_instrument_tests: 115 | api_level: '33' 116 | 117 | instrument_tests_level_32: 118 | executor: 119 | name: android/android-machine 120 | resource-class: large 121 | tag: "2024.04.1" 122 | steps: 123 | - run_instrument_tests: 124 | api_level: '32' 125 | 126 | instrument_tests_level_31: 127 | executor: 128 | name: android/android-machine 129 | resource-class: large 130 | tag: "2024.04.1" 131 | steps: 132 | - run_instrument_tests: 133 | api_level: '31' 134 | 135 | instrument_tests_level_30: 136 | executor: 137 | name: android/android-machine 138 | resource-class: large 139 | tag: "2024.04.1" 140 | steps: 141 | - run_instrument_tests: 142 | api_level: '30' 143 | 144 | instrument_tests_level_29: 145 | executor: 146 | name: android/android-machine 147 | resource-class: large 148 | tag: "2024.04.1" 149 | steps: 150 | - run_instrument_tests: 151 | api_level: '29' 152 | 153 | instrument_tests_level_28: 154 | executor: 155 | name: android/android-machine 156 | resource-class: large 157 | tag: "2024.04.1" 158 | steps: 159 | - checkout 160 | - android/start-emulator-and-run-tests: 161 | system-image: system-images;android-28;google_apis;x86 162 | - store_instrument_results 163 | 164 | 165 | workflows: 166 | version: 2 167 | test: 168 | jobs: 169 | - local_tests 170 | - instrument_tests_level_34 171 | - instrument_tests_level_33 172 | - instrument_tests_level_32 173 | - instrument_tests_level_31 174 | - instrument_tests_level_30 175 | - instrument_tests_level_29 176 | - instrument_tests_level_28 177 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @treasure-data/integrations 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | updates: 4 | # Enable workflow version updates for GitHub Actions 5 | - package-ecosystem: "github-actions" 6 | directory: "/" 7 | schedule: 8 | interval: "daily" 9 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # ******** NOTE ******** 12 | 13 | name: "CodeQL" 14 | 15 | on: 16 | push: 17 | branches: [ master ] 18 | pull_request: 19 | # The branches below must be a subset of the branches above 20 | branches: [ master ] 21 | schedule: 22 | - cron: '0 0 1 * *' 23 | 24 | jobs: 25 | analyze: 26 | name: Analyze 27 | runs-on: ubuntu-latest 28 | 29 | strategy: 30 | fail-fast: false 31 | matrix: 32 | language: [ 'java' ] 33 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 34 | # Learn more... 35 | # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection 36 | 37 | steps: 38 | - name: Checkout repository 39 | uses: actions/checkout@v2 40 | 41 | # Initializes the CodeQL tools for scanning. 42 | - name: Initialize CodeQL 43 | uses: github/codeql-action/init@v2 44 | with: 45 | languages: ${{ matrix.language }} 46 | # If you wish to specify custom queries, you can do so here or in a config file. 47 | # By default, queries listed here will override any specified in a config file. 48 | # Prefix the list here with "+" to use these queries and those in the config file. 49 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 50 | 51 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 52 | # If this step fails, then you should remove it and run the build manually (see below) 53 | - name: Autobuild 54 | uses: github/codeql-action/autobuild@v2 55 | 56 | # ℹ️ Command-line programs to run using the OS shell. 57 | # 📚 https://git.io/JvXDl 58 | 59 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 60 | # and modify them (or add more) to build your code if your project 61 | # uses a compiled language 62 | 63 | #- run: | 64 | # make bootstrap 65 | # make release 66 | 67 | - name: Perform CodeQL Analysis 68 | uses: github/codeql-action/analyze@v2 69 | -------------------------------------------------------------------------------- /.github/workflows/generate-javadoc-site.yml: -------------------------------------------------------------------------------- 1 | name: Javadoc Site Generation 2 | 3 | on: 4 | push: 5 | branches: master 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | - name: Merge master -> gh-pages 13 | run: | 14 | git config user.email "ci-bot@treasure-data.com" 15 | git config user.name "ci-bot" 16 | git fetch origin gh-pages 17 | git checkout gh-pages 18 | git merge master --allow-unrelated-histories 19 | - name: Set up JDK 8 20 | uses: actions/setup-java@v2 21 | with: 22 | java-version: '8' 23 | distribution: 'temurin' 24 | - name: Grant execute permission for gradlew 25 | run: chmod +x gradlew 26 | - name: Generate Javadoc Site 27 | run: ./gradlew javadocSite 28 | - name: Deploy Javadoc site 29 | run: | 30 | git add . 31 | git commit -m "[ci-bot] Auto-generated Javadoc Site" 32 | git push 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .gradle 2 | /build/ 3 | local.properties 4 | 5 | *.iml 6 | .idea 7 | .DS_Store -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## Version 1.1.0 4 | _2024-07-10_ 5 | 6 | * New APIs enableAutoTrackingIP and disableAutoTrackingIP to track device’s IP in td_ip column 7 | 8 | ## Version 1.0.1 9 | _2024-05-30_ 10 | 11 | * Upgrade jackson-jr-objects to 2.17.0 12 | 13 | ## Version 1.0.0 14 | _2023-02-27_ 15 | 16 | * Add support for Ingest API and Ingest format 17 | * Event collector and keen format are no longer supported 18 | * New intialization APIs with removed initWithEndpoint() API 19 | 20 | ## Version 0.6.0 21 | _2021_04_22_ 22 | 23 | * Debounce upload events to fix missing event when too many upload events calls repeatedly 24 | 25 | ## Version 0.5.0 26 | _2021_01_08_ 27 | 28 | * Add support for Android TV 29 | 30 | ## Version 0.4.0 31 | _2020_11_06_ 32 | 33 | * Add Default values feature 34 | * Add resetSessionId API 35 | * Update Keen client to fix mkdir issue 36 | 37 | ## Version 0.3.0 38 | _2019_10_14_ 39 | 40 | * Add feature auto tracking Advertising Id (`TreasureData#enableAutoAppendAdvertisingIdentifier()`) 41 | 42 | ## Version 0.2.0 43 | _2019-06-13_ 44 | 45 | * Add support for ProfileAPI (`TreasureData#fetchUserSegments()`) 46 | 47 | ## Version 0.1.19 48 | _2018-12-20_ 49 | 50 | * Add auto in-app purchase event tracking 51 | 52 | ## Version 0.1.18 53 | _2018-08-03_ 54 | 55 | * Auto event tracking is optional and off by default 56 | * Add functions for auto tracking events and custom event opt out 57 | * Add Opt Out example for td-android-sdk-demo 58 | 59 | ## Version 0.1.17 60 | _2018-03-01_ 61 | 62 | * Add Auto tracking events 63 | * Update TreasureData#sharedInstance to be singleton 64 | 65 | ## Version 0.1.16 66 | _2017-03-13_ 67 | 68 | * Add TreasureData#getSessionId 69 | 70 | ## Version 0.1.15 71 | _2017-02-17_ 72 | 73 | * Add TreasureData#setMaxUploadEventsAtOnce(int maxUploadEventsAtOnce) 74 | * Upload at most limited number (default: 400) of events at once to prevent OOM 75 | 76 | ## Version 0.1.14 77 | _2016-09-30_ 78 | 79 | * Add TreasureData#enableAutoAppendRecordUUID() 80 | * Add TreasureData#enableServerSideUploadTimestamp(String columnName) 81 | 82 | ## Version 0.1.13 83 | _2016-06-23_ 84 | 85 | * Add TreasureData.getSessionId(Context) 86 | * Fix the bug that the second call of TreasureData.startSession(Context) unexpectedly updates the session ID without calling TreasureData.endSession(Context) 87 | 88 | ## Version 0.1.12 89 | _2016-04-08_ 90 | 91 | * Add TreasureData.setSessionTimeoutMilli() 92 | 93 | ## Version 0.1.11 94 | _2016-02-10_ 95 | 96 | * Add a pair of class methods TreasureData.startSession() and TreasureData.endSession() that manages a global session tracking over Contexts. Even after TreasureData.endSession() is called and the activity is destroyed, it'll continue the same session when TreasureData.startSession() is called again within 10 seconds 97 | * Append application package version information to each event if TreasureData#enableAutoAppendAppInformation() is called 98 | * Append locale configuration information to each event if TreasureData#enableAutoAppendLocaleInformation() is called 99 | 100 | ## Version 0.1.10 101 | _2016-02-05_ 102 | 103 | * Fix the bug that can cause a failure of sending HTTP request 104 | 105 | ## Version 0.1.9 106 | _2016-01-07_ 107 | 108 | * Enable server side upload timestamp 109 | 110 | ## Version 0.1.8 111 | 112 | * Remove confusable and useless APIs 113 | * Improve the retry interval of HTTP request 114 | * Reduce the number of methods in sharded jar file and the library file size 115 | 116 | ## Version 0.1.7 (skipped) 117 | 118 | ## Version 0.1.6 119 | 120 | * Append device model infromation and persistent UUID which is generated at the first launch to each event if it's turned on 121 | * Add session id 122 | * Add first run flag so that the application detects the first launch 123 | * Retry uploading 124 | * Remove gd_bundle.crt from Java source file 125 | 126 | ## Version 0.1.5 127 | 128 | * Fix some minor bugs 129 | 130 | ## Version 0.1.4 131 | 132 | * Fix some bugs related to encryption 133 | 134 | ## Version 0.1.3 135 | 136 | * Improve error handling with TreasureData#addEventWithCallback() and TreasureData#uploadEventsWithCallback() 137 | * Enable the encryption of bufferred event data with TreasureData.initializeEncryptionKey() 138 | 139 | ## Version 0.1.2 140 | 141 | * Implement gd_bundle.crt into Java source file 142 | 143 | ## Version 0.1.1 144 | 145 | * Add shaded jar file 146 | 147 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright 2014 Treasure Data Inc. 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | 204 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting a Vulnerability 4 | 5 | Treasure Data values the security of its customers and is committed to ensuring that the systems and products are secure. We invite all bug bounty researchers to join our efforts in identifying and reporting vulnerabilities in our systems. 6 | 7 | Submit your findings to our dedicated bug bounty email address [vulnerabilities@treasuredata.com](mailto:vulnerabilities@treasuredata.com) and help us keep Treasure Data secure. Let’s work together to make the Internet a safer place! 8 | -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | repositories { 3 | mavenCentral() 4 | google() 5 | } 6 | dependencies { 7 | classpath 'com.android.tools.build:gradle:8.4.0' 8 | } 9 | } 10 | 11 | plugins { 12 | id 'java' 13 | id 'maven-publish' 14 | id 'signing' 15 | } 16 | 17 | group = 'com.treasuredata' 18 | version = '1.1.0' 19 | description = 'Android SDK for Treasure Data Cloud' 20 | 21 | java.sourceCompatibility = JavaVersion.VERSION_1_8 22 | java.targetCompatibility = JavaVersion.VERSION_1_8 23 | 24 | repositories { 25 | mavenCentral() 26 | mavenLocal() 27 | google() 28 | } 29 | 30 | dependencies { 31 | implementation 'org.komamitsu:android-logger-bridge:0.0.2' 32 | implementation 'com.fasterxml.jackson.jr:jackson-jr-objects:2.17.0' 33 | implementation 'com.treasuredata:keen-client-java-core:3.0.1' 34 | 35 | compileOnly 'com.google.android:android:4.1.1.4' 36 | 37 | // testImplementation 'com.google.android.gms:play-services-ads:23.0.0' 38 | testImplementation 'junit:junit:4.13.2' 39 | testImplementation 'org.mockito:mockito-core:5.3.1' 40 | testImplementation 'com.squareup.okhttp3:mockwebserver:3.6.0' 41 | testImplementation 'com.google.android:android:4.1.1.4' 42 | } 43 | 44 | task fatJar(type: Jar) { 45 | duplicatesStrategy = DuplicatesStrategy.EXCLUDE 46 | dependsOn configurations.runtimeClasspath 47 | from { configurations.compileClasspath.collect { it.isDirectory() ? it : zipTree(it) } } 48 | with jar 49 | // The name "shaded" here is just to be consistent with old packaged 50 | // when we were still maven-shade-plugin to make the fat JAR, no classes are actually get shaded or shadowed. 51 | // We could this into "-all" classifier, which is less misleading 52 | archiveClassifier = 'shaded' 53 | } 54 | 55 | task sourcesJar(type: Jar) { 56 | from sourceSets.main.allJava 57 | archiveClassifier = 'sources' 58 | } 59 | 60 | task javadocJar(type: Jar) { 61 | from javadoc 62 | archiveClassifier = 'javadoc' 63 | } 64 | 65 | task javadocSite(type: Javadoc) { 66 | failOnError true 67 | source = sourceSets.main.allJava 68 | classpath = sourceSets.main.compileClasspath + sourceSets.main.output // This is to compile excluded internal files as well 69 | include 'com/treasuredata/android/**' 70 | exclude 'com/treasuredata/android/billing/internal/**' 71 | options.noTimestamp true 72 | destinationDir = file("${rootDir}/docs") 73 | } 74 | 75 | publishing { 76 | publications { 77 | mavenJava(MavenPublication) { 78 | from components.java 79 | artifact fatJar 80 | artifact sourcesJar 81 | artifact javadocJar 82 | pom { 83 | name = 'Android SDK for Treasure Data Cloud' 84 | description = 'Android SDK for Treasure Data Cloud' 85 | url = 'https://github.com/treasure-data/td-android-sdk' 86 | licenses { 87 | license { 88 | name = 'The Apache License, Version 2.0' 89 | url = 'http://www.apache.org/licenses/LICENSE-2.0.txt' 90 | distribution = 'repo' 91 | } 92 | } 93 | developers { 94 | developer { 95 | id = 'komamitsu' 96 | name = 'Mitsunori Komatsu' 97 | email = 'komamitsu@gmail.com' 98 | } 99 | developer { 100 | id = 'mcaramello' 101 | name = 'Michele Caramello' 102 | email = 'michele.caramello@gmail.com' 103 | } 104 | developer { 105 | id = 'huylenq' 106 | name = 'Huy Le' 107 | email = 'huy.lenq@gmail.com' 108 | } 109 | } 110 | scm { 111 | connection = 'scm:git:https://github.com/treasure-data/td-android-sdk.git' 112 | developerConnection = 'scm:git:git@github.com:treasure-data/td-android-sdk.git' 113 | url = 'https://github.com/treasure-data/td-android-sdk' 114 | } 115 | 116 | issueManagement { 117 | system = 'GitHub' 118 | url = 'https://github.com/treasure-data/td-android-sdk/issues' 119 | } 120 | } 121 | } 122 | } 123 | repositories { 124 | maven { 125 | def releasesRepoUrl = 'https://oss.sonatype.org/service/local/staging/deploy/maven2/' 126 | def snapshotsRepoUrl = 'https://oss.sonatype.org/content/repositories/snapshots' 127 | url = version.endsWith('SNAPSHOT') ? snapshotsRepoUrl : releasesRepoUrl 128 | credentials { 129 | username findProperty('sonatypeTokenUsername') 130 | password findProperty('sonatypeTokenPassword') 131 | } 132 | } 133 | } 134 | } 135 | 136 | signing { 137 | sign publishing.publications.mavenJava 138 | } 139 | -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | /build/ 2 | /bin/ -------------------------------------------------------------------------------- /example/build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | repositories { 3 | mavenCentral() 4 | google() 5 | } 6 | dependencies { 7 | classpath 'com.android.tools.build:gradle:8.4.0' 8 | } 9 | } 10 | 11 | apply plugin: 'com.android.application' 12 | 13 | android { 14 | defaultConfig { 15 | applicationId "com.treasuredata.android.demo" 16 | compileSdk 34 17 | minSdkVersion 21 18 | targetSdkVersion 34 19 | versionCode 1 20 | versionName "1.0" 21 | testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" 22 | } 23 | 24 | buildTypes { 25 | release { 26 | minifyEnabled false 27 | proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.txt' 28 | } 29 | } 30 | namespace 'com.treasuredata.android.demo' 31 | } 32 | 33 | repositories { 34 | jcenter() 35 | google() 36 | mavenCentral() 37 | mavenLocal() // FIXME: remove 38 | } 39 | 40 | dependencies { 41 | implementation rootProject 42 | implementation 'com.google.android.gms:play-services-ads:23.0.0' 43 | implementation 'com.android.installreferrer:installreferrer:2.2' 44 | 45 | testImplementation 'junit:junit:4.13.2' 46 | androidTestImplementation 'com.android.support.test:runner:1.0.2' 47 | 48 | } -------------------------------------------------------------------------------- /example/proguard-project.txt: -------------------------------------------------------------------------------- 1 | # To enable ProGuard in your project, edit project.properties 2 | # to define the proguard.config property as described in that file. 3 | # 4 | # Add project specific ProGuard rules here. 5 | # By default, the flags in this file are appended to flags specified 6 | # in ${sdk.dir}/tools/proguard/proguard-android.txt 7 | # You can edit the include path and order by changing the ProGuard 8 | # include property in project.properties. 9 | # 10 | # For more details, see 11 | # http://developer.android.com/guide/developing/tools/proguard.html 12 | 13 | # Add any project specific keep options here: 14 | 15 | # If your project uses WebView with JS, uncomment the following 16 | # and specify the fully qualified class name to the JavaScript interface 17 | # class: 18 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 19 | # public *; 20 | #} 21 | -------------------------------------------------------------------------------- /example/project.properties: -------------------------------------------------------------------------------- 1 | # This file is automatically generated by Android Tools. 2 | # Do not modify this file -- YOUR CHANGES WILL BE ERASED! 3 | # 4 | # This file must be checked in Version Control Systems. 5 | # 6 | # To customize properties used by the Ant build system edit 7 | # "ant.properties", and override values to adapt the script to your 8 | # project structure. 9 | # 10 | # To enable ProGuard to shrink and obfuscate your code, uncomment this (available properties: sdk.dir, user.home): 11 | #proguard.config=${sdk.dir}/tools/proguard/proguard-android.txt:proguard-project.txt 12 | 13 | # Project target. 14 | target=android-19 15 | -------------------------------------------------------------------------------- /example/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 9 | 12 | 13 | 20 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 35 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /example/src/main/java/com/treasuredata/android/demo/DemoApp.java: -------------------------------------------------------------------------------- 1 | package com.treasuredata.android.demo; 2 | 3 | import android.app.Application; 4 | import android.os.RemoteException; 5 | 6 | import com.android.installreferrer.api.InstallReferrerClient; 7 | import com.android.installreferrer.api.InstallReferrerStateListener; 8 | import com.android.installreferrer.api.ReferrerDetails; 9 | import com.treasuredata.android.TreasureData; 10 | 11 | import java.util.HashMap; 12 | import java.util.Map; 13 | 14 | /** 15 | * Created by vinhvd on 2/6/18. 16 | */ 17 | 18 | public class DemoApp extends Application { 19 | InstallReferrerClient referrerClient; 20 | 21 | @Override 22 | public void onCreate() { 23 | super.onCreate(); 24 | setupTreasureData(); 25 | setupInstallReferrer(); 26 | } 27 | 28 | private void setupTreasureData() { 29 | TreasureData.enableLogging(); 30 | TreasureData.initializeEncryptionKey("hello world"); 31 | TreasureData.setSessionTimeoutMilli(30 * 1000); 32 | 33 | TreasureData.initializeSharedInstance(this, "your_write_api_key", "https://api_endpoint"); 34 | TreasureData.sharedInstance().enableAutoAppendUniqId(); 35 | TreasureData.sharedInstance().enableAutoAppendModelInformation(); 36 | TreasureData.sharedInstance().enableAutoAppendAppInformation(); 37 | TreasureData.sharedInstance().enableAutoAppendLocaleInformation(); 38 | TreasureData.sharedInstance().enableAutoAppendRecordUUID(); 39 | TreasureData.sharedInstance().enableAutoAppendAdvertisingIdentifier("custom_td_maid"); 40 | TreasureData.sharedInstance().setDefaultDatabase("default_db"); 41 | TreasureData.sharedInstance().setDefaultTable("default_table"); 42 | TreasureData.sharedInstance().setDefaultValue(null, null, "default_value", "Test default value"); 43 | 44 | } 45 | 46 | private void setupInstallReferrer() { 47 | referrerClient = InstallReferrerClient.newBuilder(this).build(); 48 | referrerClient.startConnection(new InstallReferrerStateListener() { 49 | @Override 50 | public void onInstallReferrerSetupFinished(int responseCode) { 51 | switch (responseCode) { 52 | case InstallReferrerClient.InstallReferrerResponse.OK: 53 | // Connection established. 54 | try { 55 | ReferrerDetails response = referrerClient.getInstallReferrer(); 56 | addReferrerEvent(response); 57 | } catch (RemoteException e) { 58 | e.printStackTrace(); 59 | } 60 | break; 61 | case InstallReferrerClient.InstallReferrerResponse.FEATURE_NOT_SUPPORTED: 62 | // API not available on the current Play Store app. 63 | break; 64 | case InstallReferrerClient.InstallReferrerResponse.SERVICE_UNAVAILABLE: 65 | // Connection couldn't be established. 66 | break; 67 | } 68 | } 69 | 70 | @Override 71 | public void onInstallReferrerServiceDisconnected() { 72 | // Try to restart the connection on the next request to 73 | // Google Play by calling the startConnection() method. 74 | } 75 | }); 76 | } 77 | 78 | private void addReferrerEvent(ReferrerDetails referrer) { 79 | String referrerUrl = referrer.getInstallReferrer(); 80 | long referrerClickTime = referrer.getReferrerClickTimestampSeconds(); 81 | long appInstallTime = referrer.getInstallBeginTimestampSeconds(); 82 | boolean instantExperienceLaunched = referrer.getGooglePlayInstantParam(); 83 | HashMap eventRecord = new HashMap(); 84 | eventRecord.put("type", "install_referrer"); 85 | eventRecord.put("url", referrerUrl); 86 | eventRecord.put("referrerClickTime", referrerClickTime); 87 | eventRecord.put("appInstallTime", appInstallTime); 88 | eventRecord.put("instantExperienceLaunched", instantExperienceLaunched); 89 | TreasureData.sharedInstance().addEvent("test_db", "demo_tbl", eventRecord); 90 | TreasureData.sharedInstance().uploadEvents(); 91 | } 92 | 93 | } 94 | -------------------------------------------------------------------------------- /example/src/main/res/drawable-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/treasure-data/td-android-sdk/df71c6a3aab9c0cf3ebcac858340c1c6b013e461/example/src/main/res/drawable-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /example/src/main/res/drawable-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/treasure-data/td-android-sdk/df71c6a3aab9c0cf3ebcac858340c1c6b013e461/example/src/main/res/drawable-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /example/src/main/res/drawable-xhdpi/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/treasure-data/td-android-sdk/df71c6a3aab9c0cf3ebcac858340c1c6b013e461/example/src/main/res/drawable-xhdpi/banner.png -------------------------------------------------------------------------------- /example/src/main/res/drawable-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/treasure-data/td-android-sdk/df71c6a3aab9c0cf3ebcac858340c1c6b013e461/example/src/main/res/drawable-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /example/src/main/res/drawable-xhdpi/wine.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/treasure-data/td-android-sdk/df71c6a3aab9c0cf3ebcac858340c1c6b013e461/example/src/main/res/drawable-xhdpi/wine.jpg -------------------------------------------------------------------------------- /example/src/main/res/drawable-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/treasure-data/td-android-sdk/df71c6a3aab9c0cf3ebcac858340c1c6b013e461/example/src/main/res/drawable-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /example/src/main/res/values/dimens.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 16dp 5 | 16dp 6 | 7 | 8 | -------------------------------------------------------------------------------- /example/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | TD Android SDK Demo 5 | td-android-sdk-demo 6 | Add Event 7 | Upload Event 8 | UUID 9 | Get 10 | Disable 11 | Enable 12 | Reset 13 | Auto Append Model Information 14 | Auto Append App Information 15 | Auto Append Local Information 16 | Column 17 | Server Side Upload Timestamp 18 | Event Database 19 | Event Table 20 | Auto Append Record UUID 21 | Auto Append Advertising Identifier 22 | Session 23 | Custom Event 24 | Is Enabled? 25 | App Lifecycle 26 | Get session id 27 | Start session 28 | End session id 29 | Get global session id 30 | Start global session 31 | Default value 32 | Set default value 33 | Profile API 34 | Fetch user segments 35 | Retry uploading 36 | Event compression 37 | End global session 38 | Set timeout 39 | Logging 40 | First run 41 | 42 | Is first run? 43 | Clear first run 44 | 45 | 46 | -------------------------------------------------------------------------------- /example/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 14 | 15 | 16 | 19 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | ## For more details on how to configure your build environment visit 2 | # http://www.gradle.org/docs/current/userguide/build_environment.html 3 | # 4 | # Specifies the JVM arguments used for the daemon process. 5 | # The setting is particularly useful for tweaking memory settings. 6 | # Default value: -Xmx1024m -XX:MaxPermSize=256m 7 | # org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 8 | # 9 | # When configured, Gradle will run in incubating parallel mode. 10 | # This option should only be used with decoupled projects. More details, visit 11 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 12 | # org.gradle.parallel=true 13 | #Wed Feb 15 01:31:42 ICT 2023 14 | android.useAndroidX=true 15 | android.enableJetifier=true 16 | #android.disableAutomaticComponentCreation=true 17 | android.defaults.buildfeatures.buildconfig=true 18 | android.nonTransitiveRClass=false 19 | android.nonFinalResIds=false 20 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/treasure-data/td-android-sdk/df71c6a3aab9c0cf3ebcac858340c1c6b013e461/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Tue Jun 04 14:31:20 ICT 2019 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip 7 | android.useAndroidX=true 8 | android.enableJetifier=true -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Attempt to set APP_HOME 10 | # Resolve links: $0 may be a link 11 | PRG="$0" 12 | # Need this for relative symlinks. 13 | while [ -h "$PRG" ] ; do 14 | ls=`ls -ld "$PRG"` 15 | link=`expr "$ls" : '.*-> \(.*\)$'` 16 | if expr "$link" : '/.*' > /dev/null; then 17 | PRG="$link" 18 | else 19 | PRG=`dirname "$PRG"`"/$link" 20 | fi 21 | done 22 | SAVED="`pwd`" 23 | cd "`dirname \"$PRG\"`/" >/dev/null 24 | APP_HOME="`pwd -P`" 25 | cd "$SAVED" >/dev/null 26 | 27 | APP_NAME="Gradle" 28 | APP_BASE_NAME=`basename "$0"` 29 | 30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 31 | DEFAULT_JVM_OPTS='"-Xmx64m"' 32 | 33 | # Use the maximum available, or set MAX_FD != -1 to use that value. 34 | MAX_FD="maximum" 35 | 36 | warn () { 37 | echo "$*" 38 | } 39 | 40 | die () { 41 | echo 42 | echo "$*" 43 | echo 44 | exit 1 45 | } 46 | 47 | # OS specific support (must be 'true' or 'false'). 48 | cygwin=false 49 | msys=false 50 | darwin=false 51 | nonstop=false 52 | case "`uname`" in 53 | CYGWIN* ) 54 | cygwin=true 55 | ;; 56 | Darwin* ) 57 | darwin=true 58 | ;; 59 | MINGW* ) 60 | msys=true 61 | ;; 62 | NONSTOP* ) 63 | nonstop=true 64 | ;; 65 | esac 66 | 67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 68 | 69 | # Determine the Java command to use to start the JVM. 70 | if [ -n "$JAVA_HOME" ] ; then 71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 72 | # IBM's JDK on AIX uses strange locations for the executables 73 | JAVACMD="$JAVA_HOME/jre/sh/java" 74 | else 75 | JAVACMD="$JAVA_HOME/bin/java" 76 | fi 77 | if [ ! -x "$JAVACMD" ] ; then 78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 79 | 80 | Please set the JAVA_HOME variable in your environment to match the 81 | location of your Java installation." 82 | fi 83 | else 84 | JAVACMD="java" 85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 86 | 87 | Please set the JAVA_HOME variable in your environment to match the 88 | location of your Java installation." 89 | fi 90 | 91 | # Increase the maximum file descriptors if we can. 92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 93 | MAX_FD_LIMIT=`ulimit -H -n` 94 | if [ $? -eq 0 ] ; then 95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 96 | MAX_FD="$MAX_FD_LIMIT" 97 | fi 98 | ulimit -n $MAX_FD 99 | if [ $? -ne 0 ] ; then 100 | warn "Could not set maximum file descriptor limit: $MAX_FD" 101 | fi 102 | else 103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 104 | fi 105 | fi 106 | 107 | # For Darwin, add options to specify how the application appears in the dock 108 | if $darwin; then 109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 110 | fi 111 | 112 | # For Cygwin, switch paths to Windows format before running java 113 | if $cygwin ; then 114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 116 | JAVACMD=`cygpath --unix "$JAVACMD"` 117 | 118 | # We build the pattern for arguments to be converted via cygpath 119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 120 | SEP="" 121 | for dir in $ROOTDIRSRAW ; do 122 | ROOTDIRS="$ROOTDIRS$SEP$dir" 123 | SEP="|" 124 | done 125 | OURCYGPATTERN="(^($ROOTDIRS))" 126 | # Add a user-defined pattern to the cygpath arguments 127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 129 | fi 130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 131 | i=0 132 | for arg in "$@" ; do 133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 135 | 136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 138 | else 139 | eval `echo args$i`="\"$arg\"" 140 | fi 141 | i=$((i+1)) 142 | done 143 | case $i in 144 | (0) set -- ;; 145 | (1) set -- "$args0" ;; 146 | (2) set -- "$args0" "$args1" ;; 147 | (3) set -- "$args0" "$args1" "$args2" ;; 148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 154 | esac 155 | fi 156 | 157 | # Escape application args 158 | save () { 159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 160 | echo " " 161 | } 162 | APP_ARGS=$(save "$@") 163 | 164 | # Collect all arguments for the java command, following the shell quoting and substitution rules 165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 166 | 167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong 168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then 169 | cd "$(dirname "$0")" 170 | fi 171 | 172 | exec "$JAVACMD" "$@" 173 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | set DIRNAME=%~dp0 12 | if "%DIRNAME%" == "" set DIRNAME=. 13 | set APP_BASE_NAME=%~n0 14 | set APP_HOME=%DIRNAME% 15 | 16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 17 | set DEFAULT_JVM_OPTS="-Xmx64m" 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windows variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | 53 | :win9xME_args 54 | @rem Slurp the command line arguments. 55 | set CMD_LINE_ARGS= 56 | set _SKIP=2 57 | 58 | :win9xME_args_slurp 59 | if "x%~1" == "x" goto execute 60 | 61 | set CMD_LINE_ARGS=%* 62 | 63 | :execute 64 | @rem Setup the command line 65 | 66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 67 | 68 | @rem Execute Gradle 69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 70 | 71 | :end 72 | @rem End local scope for the variables with windows NT shell 73 | if "%ERRORLEVEL%"=="0" goto mainEnd 74 | 75 | :fail 76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 77 | rem the _cmd.exe /c_ return code! 78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 79 | exit /b 1 80 | 81 | :mainEnd 82 | if "%OS%"=="Windows_NT" endlocal 83 | 84 | :omega 85 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | rootProject.name = 'td-android-sdk' 2 | 3 | include ':example', ':test-host' -------------------------------------------------------------------------------- /src/main/java/com/treasuredata/android/CustomizedJSON.java: -------------------------------------------------------------------------------- 1 | package com.treasuredata.android; 2 | 3 | import com.fasterxml.jackson.core.JsonGenerator; 4 | import com.fasterxml.jackson.core.TreeCodec; 5 | import com.fasterxml.jackson.jr.ob.JSON; 6 | import com.fasterxml.jackson.jr.ob.impl.JSONWriter; 7 | import com.fasterxml.jackson.jr.ob.impl.ValueWriterLocator; 8 | 9 | import java.io.IOException; 10 | import java.text.SimpleDateFormat; 11 | import java.util.Date; 12 | 13 | public class CustomizedJSON extends JSON { 14 | private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSZ"); 15 | 16 | private static class CustomizedJSONWriter extends JSONWriter { 17 | public CustomizedJSONWriter() 18 | { 19 | super(); 20 | } 21 | 22 | protected CustomizedJSONWriter(CustomizedJSONWriter base, int features, 23 | ValueWriterLocator loc, TreeCodec tc, 24 | JsonGenerator g) 25 | { 26 | super(base, features, loc, tc, g); 27 | } 28 | 29 | private String getFormattedDate(Date date) { 30 | String dateStr; 31 | synchronized (SimpleDateFormat.class) { 32 | dateStr = DATE_FORMAT.format(date); 33 | } 34 | return dateStr; 35 | } 36 | 37 | public JSONWriter perOperationInstance(int features, 38 | ValueWriterLocator loc, TreeCodec tc, 39 | JsonGenerator g) 40 | { 41 | if (getClass() != CustomizedJSONWriter.class) { // sanity check 42 | throw new IllegalStateException("Sub-classes MUST override perOperationInstance(...)"); 43 | } 44 | return new CustomizedJSONWriter(this, features, loc, tc, g); 45 | } 46 | 47 | @Override 48 | protected void writeDateValue(Date v) throws IOException { 49 | writeStringValue(getFormattedDate(v)); 50 | } 51 | 52 | @Override 53 | protected void writeDateField(String fieldName, Date v) throws IOException { 54 | writeStringField(fieldName, getFormattedDate(v)); 55 | } 56 | } 57 | 58 | @Override 59 | protected JSONWriter _defaultWriter() { 60 | return new CustomizedJSONWriter(); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/main/java/com/treasuredata/android/Debouncer.java: -------------------------------------------------------------------------------- 1 | package com.treasuredata.android; 2 | 3 | import java.util.concurrent.ConcurrentHashMap; 4 | import java.util.concurrent.Executors; 5 | import java.util.concurrent.ScheduledExecutorService; 6 | import java.util.concurrent.TimeUnit; 7 | 8 | class Debouncer { 9 | interface Callback { 10 | void call(T arg); 11 | } 12 | 13 | private final ScheduledExecutorService sched = Executors.newScheduledThreadPool(1); 14 | private final ConcurrentHashMap delayedMap = new ConcurrentHashMap(); 15 | private final Callback callback; 16 | private final int interval; 17 | 18 | Debouncer(Callback c, int interval) { 19 | this.callback = c; 20 | this.interval = interval; 21 | } 22 | 23 | void call(T key) { 24 | TimerTask task = new TimerTask(key); 25 | 26 | TimerTask prev; 27 | do { 28 | prev = delayedMap.putIfAbsent(key, task); 29 | if (prev == null) 30 | sched.schedule(task, interval, TimeUnit.MILLISECONDS); 31 | } while (prev != null && !prev.extend()); // Exit only if new task was added to map, or existing task was extended successfully 32 | } 33 | 34 | void terminate() { 35 | sched.shutdownNow(); 36 | } 37 | 38 | // The task that wakes up when the wait time elapses 39 | private class TimerTask implements Runnable { 40 | private final T key; 41 | private long dueTime; 42 | private final Object lock = new Object(); 43 | 44 | TimerTask(T key) { 45 | this.key = key; 46 | extend(); 47 | } 48 | 49 | boolean extend() { 50 | synchronized (lock) { 51 | if (dueTime < 0) // Task has been shutdown 52 | return false; 53 | dueTime = System.currentTimeMillis() + interval; 54 | return true; 55 | } 56 | } 57 | 58 | public void run() { 59 | synchronized (lock) { 60 | long remaining = dueTime - System.currentTimeMillis(); 61 | if (remaining > 0) { // Re-schedule task 62 | sched.schedule(this, remaining, TimeUnit.MILLISECONDS); 63 | } else { // Mark as terminated and invoke callback 64 | dueTime = -1; 65 | try { 66 | callback.call(key); 67 | } finally { 68 | delayedMap.remove(key); 69 | } 70 | } 71 | } 72 | } 73 | } 74 | } -------------------------------------------------------------------------------- /src/main/java/com/treasuredata/android/GetAdvertisingIdAsyncTask.java: -------------------------------------------------------------------------------- 1 | package com.treasuredata.android; 2 | 3 | import android.os.AsyncTask; 4 | import android.content.Context; 5 | 6 | import org.komamitsu.android.util.Log; 7 | 8 | import java.lang.reflect.InvocationTargetException; 9 | import java.lang.reflect.Method; 10 | 11 | class GetAdvertisingIdAsyncTask extends AsyncTask { 12 | private static final String TAG = GetAdvertisingIdAsyncTask.class.getSimpleName(); 13 | private static Object advertisingInfo; 14 | private static Method isLimitAdTrackingEnabledMethod; 15 | private static Method getIdMethod; 16 | private final GetAdvertisingIdAsyncTaskCallback callback; 17 | 18 | GetAdvertisingIdAsyncTask(GetAdvertisingIdAsyncTaskCallback callback) { 19 | this.callback = callback; 20 | } 21 | 22 | synchronized private static void cacheAdvertisingInfoClass(Context context) throws Exception { 23 | try { 24 | if (advertisingInfo == null) { 25 | advertisingInfo = Class.forName("com.google.android.gms.ads.identifier.AdvertisingIdClient") 26 | .getMethod("getAdvertisingIdInfo", Context.class) 27 | .invoke(null, context); 28 | isLimitAdTrackingEnabledMethod = advertisingInfo.getClass() 29 | .getMethod("isLimitAdTrackingEnabled"); 30 | getIdMethod = advertisingInfo.getClass().getMethod("getId"); 31 | } 32 | } catch (ClassNotFoundException e) { 33 | // Customer does not include google services ad library, indicate not wanting to track Advertising Id 34 | Log.w(TAG, "Exception getting advertising id: " + e.getMessage(), e); 35 | Log.w(TAG, "You are attempting to enable auto append Advertising Identifier but AdvertisingIdClient class is not detected. To use this feature, you must use Google Mobile Ads library"); 36 | } catch (Exception e) { 37 | throw e; 38 | } 39 | } 40 | 41 | @Override 42 | protected String doInBackground(Context... params) { 43 | Context context = params[0]; 44 | try { 45 | cacheAdvertisingInfoClass(context); 46 | if (advertisingInfo != null && !(Boolean) isLimitAdTrackingEnabledMethod.invoke(advertisingInfo)) { 47 | return (String) getIdMethod.invoke(advertisingInfo); 48 | } else { 49 | return null; 50 | } 51 | } catch (Exception e) { 52 | Log.w(TAG, "Exception getting advertising id: " + e.getMessage(), e); 53 | return null; 54 | } 55 | } 56 | 57 | @Override 58 | protected void onPostExecute(String advertisingId) { 59 | callback.onGetAdvertisingIdAsyncTaskCompleted(advertisingId); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/main/java/com/treasuredata/android/GetAdvertisingIdAsyncTaskCallback.java: -------------------------------------------------------------------------------- 1 | package com.treasuredata.android; 2 | 3 | interface GetAdvertisingIdAsyncTaskCallback { 4 | void onGetAdvertisingIdAsyncTaskCompleted(String advertisingId); 5 | } 6 | -------------------------------------------------------------------------------- /src/main/java/com/treasuredata/android/Session.java: -------------------------------------------------------------------------------- 1 | package com.treasuredata.android; 2 | 3 | import java.util.UUID; 4 | 5 | public class Session { 6 | public static final long DEFAULT_SESSION_PENDING_MILLIS = 10 * 1000; 7 | private final long sessionPendingMillis; 8 | private String id; 9 | private Long finishedAt; 10 | 11 | public Session() { 12 | this(DEFAULT_SESSION_PENDING_MILLIS); 13 | } 14 | 15 | public Session(long sessionPendingMillis) { 16 | this.sessionPendingMillis = sessionPendingMillis; 17 | } 18 | 19 | public synchronized void start() { 20 | if (id == null || (finishedAt != null && (System.currentTimeMillis() - finishedAt) > sessionPendingMillis)) { 21 | id = UUID.randomUUID().toString(); 22 | } 23 | finishedAt = null; 24 | } 25 | 26 | public synchronized void finish() { 27 | // Checking `id` just for case of calling finish() first before start() 28 | if (id != null && finishedAt == null) { 29 | finishedAt = System.currentTimeMillis(); 30 | } 31 | } 32 | 33 | public synchronized String getId() { 34 | if (id == null || finishedAt != null) { 35 | return null; 36 | } 37 | return id; 38 | } 39 | 40 | public synchronized void resetId() { 41 | id = UUID.randomUUID().toString(); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/main/java/com/treasuredata/android/TDCallback.java: -------------------------------------------------------------------------------- 1 | package com.treasuredata.android; 2 | 3 | public interface TDCallback { 4 | void onSuccess(); 5 | 6 | void onError(String errorCode, Exception e); 7 | } 8 | -------------------------------------------------------------------------------- /src/main/java/com/treasuredata/android/TDClient.java: -------------------------------------------------------------------------------- 1 | package com.treasuredata.android; 2 | 3 | import io.keen.client.java.GlobalPropertiesEvaluator; 4 | import io.keen.client.java.KeenClient; 5 | import io.keen.client.java.KeenProject; 6 | import org.komamitsu.android.util.Log; 7 | 8 | import java.io.File; 9 | import java.io.IOException; 10 | import java.security.MessageDigest; 11 | import java.security.NoSuchAlgorithmException; 12 | import java.util.HashMap; 13 | import java.util.Map; 14 | import java.util.UUID; 15 | import java.util.concurrent.Executors; 16 | 17 | class TDClient extends KeenClient { 18 | private static final String TAG = TDClient.class.getSimpleName(); 19 | private static String defaultApiEndpoint = "https://us01.records.in.treasuredata.com"; 20 | private static String encryptionKey; 21 | 22 | TDClient(String apiKey, String apiEndpoint, File eventStoreRoot) throws IOException { 23 | super( 24 | new TDClientBuilder() 25 | .withHttpHandler(new TDHttpHandler(apiKey, apiEndpoint == null ? defaultApiEndpoint : apiEndpoint)) 26 | .withEventStore(new TDEventStore(eventStoreRoot)) 27 | .withJsonHandler(new TDJsonHandler(encryptionKey)) 28 | .withPublishExecutor(Executors.newSingleThreadExecutor()) 29 | ); 30 | 31 | // setDebugMode(true); 32 | setApiKey(apiKey); 33 | setActive(true); 34 | setGlobalPropertiesEvaluator(new GlobalPropertiesEvaluator() { 35 | @Override 36 | public Map getGlobalProperties(String s) { 37 | Map properties = new HashMap(1); 38 | properties.put("uuid", UUID.randomUUID().toString()); 39 | return properties; 40 | } 41 | }); 42 | setBaseUrl(apiEndpoint == null ? defaultApiEndpoint : apiEndpoint); 43 | } 44 | 45 | public static void setEncryptionKey(String encryptionKey) { 46 | TDClient.encryptionKey = encryptionKey; 47 | } 48 | 49 | public void disableAutoRetryUploading() { 50 | enableRetryUploading = false; 51 | } 52 | 53 | public void enableAutoRetryUploading() { 54 | enableRetryUploading = true; 55 | } 56 | 57 | // Only for test 58 | @Deprecated 59 | TDClient(String apiKey) { 60 | super( 61 | new TDClientBuilder() 62 | .withHttpHandler(new TDHttpHandler(apiKey, "test-endpoint")) 63 | ); 64 | setApiKey(apiKey); 65 | } 66 | 67 | private void setApiKey(String apiKey) { 68 | String projectId; 69 | try { 70 | projectId = createProjectIdFromApiKey(apiKey); 71 | } catch (Exception e) { 72 | Log.e(TAG, "Failed to create md5 instance", e); 73 | projectId = "_td default"; 74 | } 75 | KeenProject project = new KeenProject(projectId, "dummy_write_key", "dummy_read_key"); 76 | setDefaultProject(project); 77 | } 78 | 79 | private String createProjectIdFromApiKey(String apiKey) throws NoSuchAlgorithmException { 80 | StringBuilder hexString = new StringBuilder(); 81 | MessageDigest md5 = MessageDigest.getInstance("MD5"); 82 | byte[] hash = md5.digest(apiKey.getBytes()); 83 | 84 | for (byte aHash : hash) { 85 | if ((0xff & aHash) < 0x10) { 86 | hexString.append("0").append(Integer.toHexString((0xFF & aHash))); 87 | } else { 88 | hexString.append(Integer.toHexString(0xFF & aHash)); 89 | } 90 | } 91 | return "_td " + hexString.toString(); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/main/java/com/treasuredata/android/TDClientBuilder.java: -------------------------------------------------------------------------------- 1 | package com.treasuredata.android; 2 | 3 | import io.keen.client.java.KeenClient; 4 | import io.keen.client.java.KeenJsonHandler; 5 | 6 | class TDClientBuilder extends KeenClient.Builder { 7 | @Override 8 | protected KeenJsonHandler getDefaultJsonHandler() throws Exception { 9 | return null; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/main/java/com/treasuredata/android/TDEventStore.java: -------------------------------------------------------------------------------- 1 | package com.treasuredata.android; 2 | 3 | import io.keen.client.java.FileEventStore; 4 | 5 | import java.io.File; 6 | import java.io.IOException; 7 | 8 | class TDEventStore extends FileEventStore { 9 | public TDEventStore(File root) throws IOException { 10 | super(root); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/main/java/com/treasuredata/android/TDHttpHandler.java: -------------------------------------------------------------------------------- 1 | package com.treasuredata.android; 2 | 3 | import android.os.Build; 4 | import io.keen.client.java.http.Request; 5 | import io.keen.client.java.http.Response; 6 | import io.keen.client.java.http.UrlConnectionHttpHandler; 7 | 8 | import java.io.*; 9 | import java.net.HttpURLConnection; 10 | import java.util.zip.GZIPOutputStream; 11 | 12 | class TDHttpHandler extends UrlConnectionHttpHandler { 13 | static volatile String VERSION = "0.0.0"; 14 | private static volatile boolean isEventCompression = true; 15 | 16 | private final String apiKey; 17 | private final String apiEndpoint; 18 | 19 | volatile boolean isTrackingIPEnabled = false; 20 | 21 | public static void disableEventCompression() { 22 | isEventCompression = false; 23 | } 24 | 25 | public static void enableEventCompression() { 26 | isEventCompression = true; 27 | } 28 | 29 | public TDHttpHandler(String apiKey, String apiEndpoint) { 30 | if (apiKey == null) { 31 | throw new IllegalArgumentException("apiKey is null"); 32 | } 33 | if (apiEndpoint == null) { 34 | throw new IllegalArgumentException("apiEndpoint is null"); 35 | } 36 | this.apiKey = apiKey; 37 | this.apiEndpoint = apiEndpoint; 38 | } 39 | 40 | protected void sendRequest(HttpURLConnection connection, Request request) throws IOException { 41 | String contentType = isTrackingIPEnabled ? "application/vnd.treasuredata.v1.mobile+json" : "application/vnd.treasuredata.v1+json"; 42 | connection.setRequestMethod("POST"); 43 | connection.setRequestProperty("Authorization", "TD1 " + apiKey); 44 | connection.setRequestProperty("Content-Type", contentType); 45 | connection.setRequestProperty("Accept", contentType); 46 | connection.setRequestProperty("User-Agent", String.format("TD-Android-SDK/%s (%s %s)", VERSION, Build.MODEL, Build.VERSION.RELEASE)); 47 | connection.setDoOutput(true); 48 | 49 | if (isEventCompression) { 50 | connection.setRequestProperty("Content-Encoding", "gzip"); 51 | ByteArrayOutputStream srcOutputStream = new ByteArrayOutputStream(); 52 | request.body.writeTo(srcOutputStream); 53 | byte[] srcBytes = srcOutputStream.toByteArray(); 54 | 55 | GZIPOutputStream gzipOutputStream = new GZIPOutputStream(connection.getOutputStream()); 56 | BufferedOutputStream bufferedOutputStream = new BufferedOutputStream(gzipOutputStream); 57 | bufferedOutputStream.write(srcBytes); 58 | bufferedOutputStream.close(); 59 | } 60 | else { 61 | request.body.writeTo(connection.getOutputStream()); 62 | connection.getOutputStream().close(); 63 | } 64 | } 65 | 66 | @Override 67 | protected Response readResponse(HttpURLConnection connection) throws IOException { 68 | return super.readResponse(connection); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/main/java/com/treasuredata/android/TDJsonHandler.java: -------------------------------------------------------------------------------- 1 | package com.treasuredata.android; 2 | 3 | import android.util.Base64; 4 | import com.fasterxml.jackson.jr.ob.JSON; 5 | import io.keen.client.java.KeenJsonHandler; 6 | import org.komamitsu.android.util.Log; 7 | 8 | import javax.crypto.BadPaddingException; 9 | import javax.crypto.Cipher; 10 | import javax.crypto.IllegalBlockSizeException; 11 | import javax.crypto.spec.SecretKeySpec; 12 | import java.io.*; 13 | import java.nio.charset.Charset; 14 | import java.security.InvalidKeyException; 15 | import java.security.MessageDigest; 16 | import java.util.Map; 17 | 18 | class TDJsonHandler implements KeenJsonHandler { 19 | private static final String TAG = TDJsonHandler.class.getSimpleName(); 20 | private static final Base64Encoder DEFAULT_BASE64_ENCODER = new Base64Encoder() { 21 | @Override 22 | public String encode(byte[] data) 23 | { 24 | return Base64.encodeToString(data, Base64.DEFAULT); 25 | } 26 | 27 | @Override 28 | public byte[] decode(String encoded) 29 | { 30 | return Base64.decode(encoded, Base64.DEFAULT); 31 | } 32 | }; 33 | private SecretKeySpec secretKeySpec; 34 | private final Cipher cipher; 35 | private static final Charset UTF8 = Charset.forName("UTF-8"); 36 | private final Base64Encoder base64Encoder; 37 | 38 | public interface Base64Encoder { 39 | String encode(byte[] data); 40 | 41 | byte[] decode(String encoded); 42 | } 43 | 44 | @Override 45 | public Map readJson(Reader reader) throws IOException { 46 | return readJson(reader, false); 47 | } 48 | 49 | @Override 50 | public Map readJsonWithoutDecryption(Reader reader) throws IOException { 51 | return readJson(reader, true); 52 | } 53 | 54 | private Map readJson(Reader reader, boolean withoutDecryption) throws IOException { 55 | if (withoutDecryption || secretKeySpec == null) { 56 | try { 57 | return json.mapFrom(reader); 58 | } catch (Exception e) { 59 | Log.w(TAG, "This event can't be handled as a plain", e); 60 | return null; 61 | } 62 | } 63 | else { 64 | BufferedReader bufferedReader = new BufferedReader(reader); 65 | StringBuilder buf = new StringBuilder(); 66 | while (true) { 67 | String line = bufferedReader.readLine(); 68 | if (line == null) 69 | break; 70 | 71 | buf.append(line).append("\n"); 72 | } 73 | 74 | String data = buf.toString(); 75 | try { 76 | byte[] decryptedBytes = decrypt(base64Encoder.decode(data)); 77 | return json.mapFrom(new String(decryptedBytes, UTF8)); 78 | } catch (Exception e) { 79 | Log.w(TAG, "Decryption failed. Trying to handle this event as a plain", e); 80 | try { 81 | return json.mapFrom(data); 82 | } catch (Exception ee) { 83 | Log.w(TAG, "This event can't be handled as a plain", ee); 84 | return null; 85 | } 86 | } 87 | } 88 | } 89 | 90 | /** 91 | * {@inheritDoc} 92 | */ 93 | @Override 94 | public void writeJson(Writer writer, Map value) throws IOException { 95 | writeJson(writer, value, false); 96 | } 97 | 98 | @Override 99 | public void writeJsonWithoutEncryption(Writer writer, Map value) throws IOException { 100 | writeJson(writer, value, true); 101 | } 102 | 103 | private void writeJson(Writer writer, Map value, boolean withoutEncryption) throws IOException { 104 | if (withoutEncryption || secretKeySpec == null) { 105 | writer.append(json.asString(value)); 106 | } 107 | else { 108 | ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); 109 | BufferedWriter bufferedWriter = new BufferedWriter(new OutputStreamWriter(byteArrayOutputStream)); 110 | bufferedWriter.append(json.asString(value)); 111 | bufferedWriter.close(); 112 | try { 113 | byte[] encryptedBytes = encrypt(byteArrayOutputStream.toByteArray()); 114 | String base64 = base64Encoder.encode(encryptedBytes); 115 | writer.write(base64); 116 | } catch (Exception e) { 117 | Log.w(TAG, "Encryption failed. Storing this event as a plain", e); 118 | secretKeySpec = null; 119 | writer.append(json.asString(value)); 120 | } 121 | } 122 | writer.close(); 123 | } 124 | 125 | private byte[] encrypt(byte[] data) throws InvalidKeyException, BadPaddingException, IllegalBlockSizeException { 126 | cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec); 127 | return cipher.doFinal(data); 128 | } 129 | 130 | private byte[] decrypt(byte[] encData) throws InvalidKeyException, BadPaddingException, IllegalBlockSizeException { 131 | cipher.init(Cipher.DECRYPT_MODE, secretKeySpec); 132 | return cipher.doFinal(encData); 133 | } 134 | 135 | ///// DEFAULT ACCESS CONSTRUCTORS ///// 136 | 137 | /** 138 | * Constructs a new Jackson JSON handler. 139 | */ 140 | TDJsonHandler() { 141 | this(null); 142 | } 143 | 144 | // Exposing this API for testing 145 | TDJsonHandler(String encryptionKeyword, Base64Encoder base64Encoder) { 146 | SecretKeySpec secretKeySpec = null; 147 | Cipher cipher = null; 148 | if (encryptionKeyword != null) { 149 | try { 150 | MessageDigest digester = MessageDigest.getInstance("MD5"); 151 | digester.update(encryptionKeyword.getBytes(), 0, encryptionKeyword.getBytes().length); 152 | secretKeySpec = new SecretKeySpec(digester.digest(), "AES"); 153 | cipher = Cipher.getInstance("AES/ECB/PKCS5Padding"); 154 | } catch (Exception e) { 155 | e.printStackTrace(); 156 | } 157 | } 158 | this.secretKeySpec = secretKeySpec; 159 | this.cipher = cipher; 160 | if (base64Encoder == null) { 161 | this.base64Encoder = DEFAULT_BASE64_ENCODER; 162 | } 163 | else { 164 | this.base64Encoder = base64Encoder; 165 | } 166 | 167 | json = new CustomizedJSON(); 168 | } 169 | 170 | TDJsonHandler(String encryptionKeyword) { 171 | this(encryptionKeyword, null); 172 | } 173 | 174 | ///// PRIVATE CONSTANTS ///// 175 | 176 | ///// PRIVATE FIELDS ///// 177 | 178 | private final JSON json; 179 | } 180 | 181 | -------------------------------------------------------------------------------- /src/main/java/com/treasuredata/android/TDLogging.java: -------------------------------------------------------------------------------- 1 | package com.treasuredata.android; 2 | 3 | import io.keen.client.java.KeenLogging; 4 | import org.komamitsu.android.util.Log; 5 | 6 | import java.lang.reflect.Field; 7 | import java.lang.reflect.InvocationTargetException; 8 | import java.lang.reflect.Method; 9 | import java.util.logging.Handler; 10 | import java.util.logging.LogRecord; 11 | import java.util.logging.Logger; 12 | 13 | public class TDLogging { 14 | private static final String TAG = TDLogging.class.getSimpleName(); 15 | private static volatile boolean initialized; 16 | private static volatile boolean enabled; 17 | 18 | private static void initializeIfNot() { 19 | if (initialized) { 20 | return; 21 | } 22 | 23 | try { 24 | Field fieldOfLogger = KeenLogging.class.getDeclaredField("LOGGER"); 25 | fieldOfLogger.setAccessible(true); 26 | TDLoggingHandler tdLoggingHandler = new TDLoggingHandler(); 27 | Method m = Logger.class.getDeclaredMethod("addHandler", Handler.class); 28 | m.invoke(fieldOfLogger.get(null), tdLoggingHandler); 29 | initialized = true; 30 | } catch (NoSuchFieldException e) { 31 | Log.e(TAG, "enableLogging failed", e); 32 | } catch (NoSuchMethodException e) { 33 | Log.e(TAG, "enableLogging failed", e); 34 | } catch (InvocationTargetException e) { 35 | Log.e(TAG, "enableLogging failed", e); 36 | } catch (IllegalAccessException e) { 37 | Log.e(TAG, "enableLogging failed", e); 38 | } 39 | } 40 | 41 | public synchronized static void enableLogging() { 42 | initializeIfNot(); 43 | 44 | enabled = true; 45 | KeenLogging.enableLogging(); 46 | } 47 | 48 | public static void disableLogging() { 49 | enabled = false; 50 | KeenLogging.disableLogging(); 51 | } 52 | 53 | public static boolean isInitialized() { 54 | return initialized; 55 | } 56 | 57 | public static boolean isEnabled() { 58 | return enabled; 59 | } 60 | 61 | static class TDLoggingHandler extends Handler { 62 | @Override 63 | public void publish(LogRecord record) { 64 | Log.i(TAG, record.getMessage()); 65 | } 66 | 67 | @Override 68 | public void flush() { 69 | } 70 | 71 | @Override 72 | public void close() throws SecurityException { 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/main/java/com/treasuredata/android/billing/internal/BillingDelegate.java: -------------------------------------------------------------------------------- 1 | package com.treasuredata.android.billing.internal; 2 | 3 | import android.content.Context; 4 | import android.os.Bundle; 5 | import android.os.IBinder; 6 | import android.util.Log; 7 | import org.json.JSONException; 8 | import org.json.JSONObject; 9 | 10 | import java.lang.reflect.InvocationTargetException; 11 | import java.lang.reflect.Method; 12 | import java.util.ArrayList; 13 | import java.util.HashMap; 14 | import java.util.List; 15 | import java.util.Map; 16 | 17 | class BillingDelegate { 18 | 19 | private static final String TAG = BillingDelegate.class.getSimpleName(); 20 | 21 | // Method and class cache 22 | private static final HashMap methodMap = 23 | new HashMap<>(); 24 | private static final HashMap> classMap = 25 | new HashMap<>(); 26 | 27 | // In App Billing Service class names 28 | private static final String IN_APP_BILLING_SERVICE_STUB = 29 | "com.android.vending.billing.IInAppBillingService$Stub"; 30 | private static final String IN_APP_BILLING_SERVICE = 31 | "com.android.vending.billing.IInAppBillingService"; 32 | 33 | // In App Billing Service method names 34 | private static final String AS_INTERFACE = "asInterface"; 35 | private static final String GET_SKU_DETAILS = "getSkuDetails"; 36 | private static final String GET_PURCHASES = "getPurchases"; 37 | private static final String GET_PURCHASE_HISTORY = "getPurchaseHistory"; 38 | private static final String IS_BILLING_SUPPORTED = "isBillingSupported"; 39 | 40 | // In App Purchase key 41 | private static final String RESPONSE_CODE = "RESPONSE_CODE"; 42 | private static final String ITEM_ID_LIST = "ITEM_ID_LIST"; 43 | private static final String DETAILS_LIST = "DETAILS_LIST"; 44 | private static final String INAPP_PURCHASE_DATA_LIST = "INAPP_PURCHASE_DATA_LIST"; 45 | private static final String INAPP_CONTINUATION_TOKEN = "INAPP_CONTINUATION_TOKEN"; 46 | 47 | private static final int MAX_QUERY_PURCHASE_NUM = 30; 48 | private static final int PURCHASE_STOP_QUERY_TIME_SEC = 30 * 60; // 30 minutes 49 | 50 | // Billing response codes 51 | public static final int BILLING_RESPONSE_RESULT_OK = 0; 52 | 53 | private BillingDelegate() { 54 | 55 | } 56 | 57 | public static boolean hasBillingService() 58 | { 59 | try { 60 | Class.forName(IN_APP_BILLING_SERVICE_STUB); 61 | return true; 62 | } catch (ClassNotFoundException ignored) { 63 | return false; 64 | } 65 | } 66 | 67 | public static Object asInterface(Context context, IBinder service) { 68 | Object[] args = new Object[]{service}; 69 | return invokeMethod(context, IN_APP_BILLING_SERVICE_STUB, 70 | AS_INTERFACE, null, args); 71 | } 72 | 73 | public static boolean isBillingSupported(Context context, 74 | Object inAppBillingObj, String type) { 75 | if (inAppBillingObj == null) { 76 | return false; 77 | } 78 | 79 | Object[] args = new Object[]{3, context.getApplicationContext().getPackageName(), type}; 80 | Object result = invokeMethod(context, IS_BILLING_SUPPORTED, inAppBillingObj, args); 81 | 82 | return result != null && ((int) result) == 0; 83 | } 84 | 85 | /** 86 | * Returns the current SKUs owned by the user of the type and package name specified along with 87 | * purchase information and a signature of the data to be validated. 88 | * This will return all SKUs that have been purchased that have not been consumed. 89 | */ 90 | public static List getPurchases(Context context, Object inAppBillingObj, String type) { 91 | List purchases = new ArrayList<>(); 92 | 93 | if (!isBillingSupported(context, inAppBillingObj, type)) { 94 | return purchases; 95 | } 96 | 97 | String continuationToken = null; 98 | int queriedPurchaseCount = 0; 99 | 100 | do { 101 | Object[] args = new Object[]{3, context.getApplicationContext().getPackageName(), type, continuationToken}; 102 | Object resultObject = invokeMethod(context, GET_PURCHASES, inAppBillingObj, args); 103 | 104 | continuationToken = null; 105 | 106 | if (resultObject != null) { 107 | Bundle purchaseBundle = (Bundle) resultObject; 108 | int response = purchaseBundle.getInt(RESPONSE_CODE); 109 | if (response == BILLING_RESPONSE_RESULT_OK) { 110 | ArrayList purchaseDataList = 111 | purchaseBundle.getStringArrayList(INAPP_PURCHASE_DATA_LIST); 112 | 113 | if (purchaseDataList == null || purchaseDataList.isEmpty()) { 114 | break; 115 | } 116 | queriedPurchaseCount += purchaseDataList.size(); 117 | purchases.addAll(purchaseDataList); 118 | continuationToken = purchaseBundle.getString(INAPP_CONTINUATION_TOKEN); 119 | } 120 | } 121 | } while (queriedPurchaseCount < MAX_QUERY_PURCHASE_NUM 122 | && continuationToken != null); 123 | 124 | return purchases; 125 | } 126 | 127 | /** 128 | * Returns the most recent purchase made by the user for each SKU, even if that purchase is 129 | * expired, canceled, or consumed. 130 | */ 131 | public static List getPurchaseHistory(Context context, Object inAppBillingObj, String type) { 132 | List purchases = new ArrayList<>(); 133 | 134 | if (!isBillingSupported(context, inAppBillingObj, type)) { 135 | return purchases; 136 | } 137 | String continuationToken = null; 138 | int queriedPurchaseCount = 0; 139 | boolean reachTimeLimit = false; 140 | 141 | do { 142 | Object[] args = new Object[]{ 143 | 6, context.getApplicationContext().getPackageName(), type, continuationToken, new Bundle()}; 144 | continuationToken = null; 145 | 146 | Object resultObject = invokeMethod(context, IN_APP_BILLING_SERVICE, 147 | GET_PURCHASE_HISTORY, inAppBillingObj, args); 148 | 149 | if (resultObject == null) { 150 | break; 151 | } 152 | 153 | long nowSec = System.currentTimeMillis() / 1000L; 154 | Bundle purchaseBundle = (Bundle) resultObject; 155 | int response = purchaseBundle.getInt(RESPONSE_CODE); 156 | if (response == BILLING_RESPONSE_RESULT_OK) { 157 | List purchaseDataList = 158 | purchaseBundle.getStringArrayList(INAPP_PURCHASE_DATA_LIST); 159 | 160 | for (String purchaseData : purchaseDataList) { 161 | try { 162 | JSONObject purchaseJSON = new JSONObject(purchaseData); 163 | long purchaseTimeSec = 164 | purchaseJSON.getLong("purchaseTime") / 1000L; 165 | 166 | if (nowSec - purchaseTimeSec > PURCHASE_STOP_QUERY_TIME_SEC) { 167 | reachTimeLimit = true; 168 | break; 169 | } else { 170 | purchases.add(purchaseData); 171 | queriedPurchaseCount++; 172 | } 173 | } catch (JSONException e) { 174 | Log.e(TAG, "Unable to parse purchase, not a json object: ", e); 175 | } 176 | } 177 | 178 | continuationToken = purchaseBundle.getString(INAPP_CONTINUATION_TOKEN); 179 | } 180 | 181 | } while (queriedPurchaseCount < MAX_QUERY_PURCHASE_NUM 182 | && continuationToken != null 183 | && !reachTimeLimit); 184 | 185 | return purchases; 186 | } 187 | 188 | /** 189 | * Provides details of a list of SKUs 190 | * Given a list of SKUs of a valid type, this returns a map with key is each SKU id 191 | * and value is JSON string containing the productId, price, title and description. 192 | */ 193 | public static Map getSkuDetails( 194 | Context context, Object inAppBillingObj, ArrayList skuList, String type) { 195 | Map skuDetailsMap = new HashMap<>(); 196 | 197 | if (skuList.isEmpty()) { 198 | return skuDetailsMap; 199 | } 200 | 201 | if (!isBillingSupported(context, inAppBillingObj, type)) { 202 | return skuDetailsMap; 203 | } 204 | 205 | Bundle querySkus = new Bundle(); 206 | querySkus.putStringArrayList(ITEM_ID_LIST, skuList); 207 | Object[] args = new Object[]{ 208 | 3, context.getApplicationContext().getPackageName(), type, querySkus}; 209 | 210 | Object result = invokeMethod(context, IN_APP_BILLING_SERVICE, 211 | GET_SKU_DETAILS, inAppBillingObj, args); 212 | 213 | if (result != null) { 214 | Bundle bundle = (Bundle) result; 215 | int response = bundle.getInt(RESPONSE_CODE); 216 | if (response == BILLING_RESPONSE_RESULT_OK) { 217 | List skuDetailsList = bundle.getStringArrayList(DETAILS_LIST); 218 | if (skuDetailsList != null && skuList.size() == skuDetailsList.size()) { 219 | for (int i = 0; i < skuList.size(); i++) { 220 | skuDetailsMap.put(skuList.get(i), skuDetailsList.get(i)); 221 | } 222 | } 223 | } 224 | } 225 | 226 | return skuDetailsMap; 227 | } 228 | 229 | private static Object invokeMethod(Context context, String methodName, Object obj, Object[] args) { 230 | return invokeMethod(context, IN_APP_BILLING_SERVICE, methodName, obj, args); 231 | } 232 | 233 | private static Object invokeMethod(Context context, String className, String methodName, Object obj, Object[] args) { 234 | Class classObj = getClass(context, className); 235 | if (classObj == null) { 236 | return null; 237 | } 238 | 239 | Method methodObj = getMethod(classObj, methodName); 240 | if (methodObj == null) { 241 | return null; 242 | } 243 | 244 | if (obj != null) { 245 | obj = classObj.cast(obj); 246 | } 247 | 248 | try { 249 | return methodObj.invoke(obj, args); 250 | } catch (IllegalAccessException e) { 251 | Log.e(TAG, 252 | "Illegal access to method " 253 | + classObj.getName() + "." + methodObj.getName(), e); 254 | } catch (InvocationTargetException e) { 255 | Log.e(TAG, 256 | "Invocation target exception in " 257 | + classObj.getName() + "." + methodObj.getName(), e); 258 | } 259 | 260 | return null; 261 | } 262 | 263 | private static Method getMethod(Class classObj, String methodName) { 264 | Method method = methodMap.get(methodName); 265 | if (method != null) { 266 | return method; 267 | } 268 | 269 | try { 270 | Class[] paramTypes = null; 271 | switch (methodName) { 272 | case AS_INTERFACE: 273 | paramTypes = new Class[]{IBinder.class}; 274 | break; 275 | case GET_SKU_DETAILS: 276 | paramTypes = new Class[]{ 277 | Integer.TYPE, String.class, String.class, Bundle.class}; 278 | break; 279 | case IS_BILLING_SUPPORTED: 280 | paramTypes = new Class[]{ 281 | Integer.TYPE, String.class, String.class}; 282 | break; 283 | case GET_PURCHASES: 284 | paramTypes = new Class[]{ 285 | Integer.TYPE, String.class, String.class, String.class}; 286 | break; 287 | case GET_PURCHASE_HISTORY: 288 | paramTypes = new Class[]{ 289 | Integer.TYPE, String.class, String.class, String.class, Bundle.class}; 290 | break; 291 | } 292 | 293 | method = classObj.getDeclaredMethod(methodName, paramTypes); 294 | methodMap.put(methodName, method); 295 | } catch (NoSuchMethodException e) { 296 | Log.e(TAG, classObj.getName() + "." + methodName + " method is not available", e); 297 | } 298 | 299 | return method; 300 | } 301 | 302 | private static Class getClass(Context context, String className) { 303 | Class classObj = classMap.get(className); 304 | if (classObj != null) { 305 | return classObj; 306 | } 307 | 308 | try { 309 | classObj = context.getClassLoader().loadClass(className); 310 | classMap.put(className, classObj); 311 | } catch (ClassNotFoundException e) { 312 | Log.e(TAG, className + " is not available, please add " 313 | + className + " to the project's dependencies.", e); 314 | } 315 | 316 | return classObj; 317 | } 318 | } 319 | -------------------------------------------------------------------------------- /src/main/java/com/treasuredata/android/billing/internal/Purchase.java: -------------------------------------------------------------------------------- 1 | package com.treasuredata.android.billing.internal; 2 | 3 | import android.util.Log; 4 | import org.json.JSONException; 5 | import org.json.JSONObject; 6 | 7 | import java.util.HashMap; 8 | import java.util.Map; 9 | 10 | public class Purchase { 11 | private static final String TAG = Purchase.class.getSimpleName(); 12 | private String originalJson; 13 | private String skuDetail; 14 | private SubscriptionStatus subscriptionStatus; 15 | 16 | enum SubscriptionStatus { 17 | New, Expired, Cancelled, Restored 18 | } 19 | 20 | public Purchase(String originalJson) { 21 | this(originalJson, null); 22 | } 23 | 24 | public Purchase(String originalJson, SubscriptionStatus subscriptionStatus) { 25 | this.originalJson = originalJson; 26 | this.subscriptionStatus = subscriptionStatus; 27 | } 28 | 29 | public Map toRecord() { 30 | Map record = new HashMap<>(); 31 | try { 32 | JSONObject purchaseJSON = new JSONObject(originalJson); 33 | JSONObject skuDetailsJSON = new JSONObject(skuDetail); 34 | String productId = purchaseJSON.getString("productId"); 35 | String orderId = purchaseJSON.optString("orderId"); 36 | String title = skuDetailsJSON.optString("title"); 37 | String price = skuDetailsJSON.getString("price"); 38 | Long priceAmountMicros = skuDetailsJSON.getLong("price_amount_micros"); 39 | String currency = skuDetailsJSON.getString("price_currency_code"); 40 | String description = skuDetailsJSON.optString("description"); 41 | String type = skuDetailsJSON.optString("type"); 42 | Integer purchaseState = purchaseJSON.optInt("purchaseState"); 43 | String developerPayload = purchaseJSON.optString("developerPayload"); 44 | Long purchaseTime = purchaseJSON.getLong("purchaseTime"); 45 | String purchaseToken = purchaseJSON.getString("purchaseToken"); 46 | String packageName = purchaseJSON.optString("packageName"); 47 | 48 | record.put(PurchaseConstants.IAP_PRODUCT_ID, productId); 49 | record.put(PurchaseConstants.IAP_ORDER_ID, orderId); 50 | record.put(PurchaseConstants.IAP_PRODUCT_TITLE, title); 51 | record.put(PurchaseConstants.IAP_PRODUCT_PRICE, price); 52 | record.put(PurchaseConstants.IAP_PRODUCT_PRICE_AMOUNT_MICROS, priceAmountMicros); 53 | record.put(PurchaseConstants.IAP_PRODUCT_CURRENCY, currency); 54 | 55 | // Quantity is always 1 for Android IAP purchase 56 | record.put(PurchaseConstants.IAP_QUANTITY, 1); 57 | record.put(PurchaseConstants.IAP_PRODUCT_TYPE, type); 58 | record.put(PurchaseConstants.IAP_PRODUCT_DESCRIPTION, description); 59 | record.put(PurchaseConstants.IAP_PURCHASE_STATE, purchaseState); 60 | record.put(PurchaseConstants.IAP_PURCHASE_DEVELOPER_PAYLOAD, developerPayload); 61 | record.put(PurchaseConstants.IAP_PURCHASE_TIME, purchaseTime); 62 | record.put(PurchaseConstants.IAP_PURCHASE_TOKEN, purchaseToken); 63 | record.put(PurchaseConstants.IAP_PACKAGE_NAME, packageName); 64 | 65 | if (type.equals(PurchaseConstants.SUBSCRIPTION)) { 66 | Boolean autoRenewing = purchaseJSON.optBoolean("autoRenewing", 67 | false); 68 | 69 | if(subscriptionStatus == SubscriptionStatus.Expired) { 70 | autoRenewing = false; 71 | } 72 | 73 | String subscriptionPeriod = skuDetailsJSON.optString("subscriptionPeriod"); 74 | String freeTrialPeriod = skuDetailsJSON.optString("freeTrialPeriod"); 75 | String introductoryPricePeriod = skuDetailsJSON.optString("introductoryPricePeriod"); 76 | 77 | record.put(PurchaseConstants.IAP_SUBSCRIPTION_STATUS, subscriptionStatus); 78 | record.put(PurchaseConstants.IAP_SUBSCRIPTION_AUTORENEWING, autoRenewing); 79 | record.put(PurchaseConstants.IAP_SUBSCRIPTION_PERIOD, subscriptionPeriod); 80 | record.put(PurchaseConstants.IAP_FREE_TRIAL_PERIOD, freeTrialPeriod); 81 | 82 | if (!introductoryPricePeriod.isEmpty()) { 83 | record.put(PurchaseConstants.IAP_INTRO_PRICE_PERIOD, introductoryPricePeriod); 84 | Long introductoryPriceCycles = skuDetailsJSON.optLong("introductoryPriceCycles"); 85 | record.put(PurchaseConstants.IAP_INTRO_PRICE_CYCLES, introductoryPriceCycles); 86 | Long introductoryPriceAmountMicros = skuDetailsJSON.getLong("introductoryPriceAmountMicros"); 87 | record.put(PurchaseConstants.IAP_INTRO_PRICE_AMOUNT_MICROS, introductoryPriceAmountMicros); 88 | } 89 | } 90 | } catch (JSONException e) { 91 | Log.e(TAG, "Unable to parse purchase, not a json object:", e); 92 | } 93 | return record; 94 | } 95 | 96 | public void setSkuDetail(String skuDetail) { 97 | this.skuDetail = skuDetail; 98 | } 99 | 100 | public String getOriginalJson() { 101 | return originalJson; 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/main/java/com/treasuredata/android/billing/internal/PurchaseConstants.java: -------------------------------------------------------------------------------- 1 | package com.treasuredata.android.billing.internal; 2 | 3 | class PurchaseConstants { 4 | // Purchase types 5 | public static final String SUBSCRIPTION = "subs"; 6 | public static final String INAPP = "inapp"; 7 | 8 | // Purchase columns 9 | public static final String IAP_PRODUCT_ID = "td_iap_product_id"; 10 | public static final String IAP_ORDER_ID = "td_iap_order_id"; 11 | public static final String IAP_PRODUCT_PRICE = "td_iap_product_price"; 12 | public static final String IAP_PRODUCT_PRICE_AMOUNT_MICROS= "td_iap_product_price_amount_micros"; 13 | public static final String IAP_PRODUCT_CURRENCY = "td_iap_product_currency"; 14 | public static final String IAP_QUANTITY = "td_iap_quantity"; 15 | public static final String IAP_PURCHASE_TIME = "td_iap_purchase_time"; 16 | public static final String IAP_PURCHASE_TOKEN = "td_iap_purchase_token"; 17 | public static final String IAP_PURCHASE_STATE = "td_iap_purchase_state"; 18 | public static final String IAP_PURCHASE_DEVELOPER_PAYLOAD = "td_iap_purchase_developer_payload"; 19 | public static final String IAP_PRODUCT_TYPE = "td_iap_product_type"; 20 | public static final String IAP_PRODUCT_TITLE = "td_iap_product_title"; 21 | public static final String IAP_PRODUCT_DESCRIPTION = "td_iap_product_description"; 22 | public static final String IAP_PACKAGE_NAME = "td_iap_package_name"; 23 | public static final String IAP_SUBSCRIPTION_STATUS = "td_iap_subs_status"; 24 | public static final String IAP_SUBSCRIPTION_AUTORENEWING = "td_iap_subs_auto_renewing"; 25 | public static final String IAP_SUBSCRIPTION_PERIOD = "td_iap_subs_period"; 26 | public static final String IAP_FREE_TRIAL_PERIOD = "td_iap_free_trial_period"; 27 | public static final String IAP_INTRO_PRICE_AMOUNT_MICROS = "td_iap_intro_price_amount_micros"; 28 | public static final String IAP_INTRO_PRICE_PERIOD = "td_iap_intro_price_period"; 29 | public static final String IAP_INTRO_PRICE_CYCLES = "td_iap_intro_price_cycles"; 30 | } 31 | -------------------------------------------------------------------------------- /src/main/java/com/treasuredata/android/billing/internal/PurchaseEventActivityLifecycleTracker.java: -------------------------------------------------------------------------------- 1 | package com.treasuredata.android.billing.internal; 2 | 3 | import android.app.Activity; 4 | import android.app.Application; 5 | import android.content.ComponentName; 6 | import android.content.Context; 7 | import android.content.Intent; 8 | import android.content.ServiceConnection; 9 | import android.content.pm.ResolveInfo; 10 | import android.os.Bundle; 11 | import android.os.IBinder; 12 | import android.util.Log; 13 | import com.treasuredata.android.TreasureData; 14 | import org.json.JSONException; 15 | import org.json.JSONObject; 16 | 17 | import java.util.ArrayList; 18 | import java.util.HashMap; 19 | import java.util.List; 20 | import java.util.Map; 21 | import java.util.concurrent.atomic.AtomicBoolean; 22 | 23 | import static com.treasuredata.android.billing.internal.PurchaseConstants.INAPP; 24 | import static com.treasuredata.android.billing.internal.PurchaseConstants.SUBSCRIPTION; 25 | 26 | public class PurchaseEventActivityLifecycleTracker { 27 | private static final String TAG = PurchaseEventActivityLifecycleTracker.class.getSimpleName(); 28 | 29 | private static final String BILLING_ACTIVITY_NAME = 30 | "com.android.billingclient.api.ProxyBillingActivity"; 31 | 32 | private static Boolean hasBillingService = null; 33 | private static boolean hasBillingActivity = false; 34 | private static ServiceConnection serviceConnection; 35 | private static Application.ActivityLifecycleCallbacks callbacks; 36 | private static Intent serviceIntent; 37 | private static Object inAppBillingObj; 38 | 39 | private static final AtomicBoolean isTracking = new AtomicBoolean(false); 40 | private static List purchaseEventListeners = new ArrayList<>(1); 41 | 42 | public interface PurchaseEventListener { 43 | void onTrack(List purchases); 44 | } 45 | 46 | private PurchaseEventActivityLifecycleTracker() { 47 | 48 | } 49 | 50 | public static void track(PurchaseEventListener purchaseEventListener) { 51 | 52 | initialize(); 53 | if (!hasBillingService) { 54 | return; 55 | } 56 | 57 | purchaseEventListeners.add(purchaseEventListener); 58 | 59 | if (!isTracking.compareAndSet(false, true)) { 60 | return; 61 | } 62 | 63 | final Context context = TreasureData.getApplicationContext(); 64 | if (context instanceof Application) { 65 | Application application = (Application) context; 66 | application.registerActivityLifecycleCallbacks(callbacks); 67 | List intentServices = context.getPackageManager().queryIntentServices(serviceIntent, 0); 68 | if (intentServices != null && !intentServices.isEmpty()) { 69 | // Service available to handle that Intent 70 | context.bindService(serviceIntent, serviceConnection, Context.BIND_AUTO_CREATE); 71 | } else { 72 | Log.e(TAG, "Billing service is unavailable on device"); 73 | } 74 | } 75 | } 76 | 77 | private static void initialize() { 78 | if (isInitialized()) { 79 | return; 80 | } 81 | 82 | hasBillingService = BillingDelegate.hasBillingService(); 83 | 84 | if (!hasBillingService) { 85 | return; 86 | } 87 | 88 | try { 89 | Class.forName(BILLING_ACTIVITY_NAME); 90 | hasBillingActivity = true; 91 | } catch (ClassNotFoundException ignored) { 92 | hasBillingActivity = false; 93 | } 94 | 95 | PurchaseEventManager.clearAllSkuDetailsCache(); 96 | 97 | serviceIntent = new Intent("com.android.vending.billing.InAppBillingService.BIND") 98 | .setPackage("com.android.vending"); 99 | 100 | serviceConnection = new ServiceConnection() { 101 | @Override 102 | public void onServiceConnected(ComponentName name, IBinder service) { 103 | inAppBillingObj = BillingDelegate.asInterface(TreasureData.getApplicationContext(), service); 104 | } 105 | 106 | @Override 107 | public void onServiceDisconnected(ComponentName name) { 108 | 109 | } 110 | }; 111 | 112 | callbacks = new Application.ActivityLifecycleCallbacks() { 113 | @Override 114 | public void onActivityCreated(Activity activity, Bundle savedInstanceState) { 115 | 116 | } 117 | 118 | @Override 119 | public void onActivityStarted(Activity activity) { 120 | 121 | } 122 | 123 | @Override 124 | public void onActivityResumed(Activity activity) { 125 | TreasureData.getExecutor().execute(new Runnable() { 126 | @Override 127 | public void run() { 128 | final Context context = TreasureData.getApplicationContext(); 129 | 130 | // Log Purchase In app type (One-time product) for the app using In-app Billing with AIDL 131 | // (https://developer.android.com/google/play/billing/api) 132 | List purchasesInapp = PurchaseEventManager 133 | .getPurchasesInapp(context, inAppBillingObj); 134 | trackPurchases(context, purchasesInapp, INAPP); 135 | 136 | // Log Purchase subscriptions 137 | List purchasesSubs = PurchaseEventManager 138 | .getPurchasesSubs(context, inAppBillingObj); 139 | trackPurchases(context, purchasesSubs, SUBSCRIPTION); 140 | } 141 | }); 142 | } 143 | 144 | @Override 145 | public void onActivityPaused(Activity activity) { 146 | 147 | } 148 | 149 | @Override 150 | public void onActivityStopped(Activity activity) { 151 | // Log Purchase In app type (One-time product) for the app using the Google Play Billing Library 152 | // (https://developer.android.com/google/play/billing/billing_library_overview) 153 | if (hasBillingActivity 154 | && activity.getLocalClassName().equals(BILLING_ACTIVITY_NAME)) { 155 | TreasureData.getExecutor().execute(new Runnable() { 156 | @Override 157 | public void run() { 158 | final Context context = TreasureData.getApplicationContext(); 159 | 160 | // First, retrieve the One-time products which have not been consumed 161 | List purchases = PurchaseEventManager 162 | .getPurchasesInapp(context, inAppBillingObj); 163 | 164 | // Second, retrieve the One-time products which have been consumed 165 | if (purchases.isEmpty()) { 166 | purchases = PurchaseEventManager 167 | .getPurchaseHistoryInapp(context, inAppBillingObj); 168 | } 169 | 170 | trackPurchases(context, purchases, INAPP); 171 | } 172 | }); 173 | } 174 | } 175 | 176 | @Override 177 | public void onActivitySaveInstanceState(Activity activity, Bundle outState) { 178 | 179 | } 180 | 181 | @Override 182 | public void onActivityDestroyed(Activity activity) { 183 | 184 | } 185 | }; 186 | } 187 | 188 | private static boolean isInitialized() { 189 | return hasBillingService != null; 190 | } 191 | 192 | private static void trackPurchases(final Context context, List purchases, String type) { 193 | if (purchases.isEmpty()) { 194 | return; 195 | } 196 | 197 | final Map purchaseMap = new HashMap<>(); 198 | List skuList = new ArrayList<>(); 199 | for (Purchase purchase : purchases) { 200 | try { 201 | JSONObject purchaseJson = new JSONObject(purchase.getOriginalJson()); 202 | String sku = purchaseJson.getString("productId"); 203 | purchaseMap.put(sku, purchase); 204 | 205 | skuList.add(sku); 206 | } catch (JSONException e) { 207 | Log.e(TAG, "Unable to parse purchase, not a json object:.", e); 208 | } 209 | } 210 | 211 | final Map skuDetailsMap = PurchaseEventManager.getAndCacheSkuDetails( 212 | context, inAppBillingObj, skuList, type); 213 | 214 | List purchaseList = new ArrayList<>(); 215 | for (Map.Entry entry : skuDetailsMap.entrySet()) { 216 | Purchase purchase = purchaseMap.get(entry.getKey()); 217 | purchase.setSkuDetail(entry.getValue()); 218 | purchaseList.add(purchase); 219 | } 220 | 221 | for (PurchaseEventListener purchaseEventListener : purchaseEventListeners) { 222 | purchaseEventListener.onTrack(purchaseList); 223 | } 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /src/main/java/com/treasuredata/android/billing/internal/PurchaseEventManager.java: -------------------------------------------------------------------------------- 1 | package com.treasuredata.android.billing.internal; 2 | 3 | import android.content.Context; 4 | import android.content.SharedPreferences; 5 | import android.util.Log; 6 | import com.treasuredata.android.TreasureData; 7 | import org.json.JSONException; 8 | import org.json.JSONObject; 9 | 10 | import java.util.ArrayList; 11 | import java.util.HashMap; 12 | import java.util.HashSet; 13 | import java.util.List; 14 | import java.util.Map; 15 | import java.util.Set; 16 | 17 | import static com.treasuredata.android.billing.internal.PurchaseConstants.INAPP; 18 | import static com.treasuredata.android.billing.internal.PurchaseConstants.SUBSCRIPTION; 19 | 20 | class PurchaseEventManager { 21 | private static final String TAG = PurchaseEventManager.class.getSimpleName(); 22 | 23 | private static final String SKU_DETAILS_SHARED_PREF_NAME = 24 | "td_sdk_sku_details"; 25 | private static final String PURCHASE_INAPP_SHARED_PREF_NAME = 26 | "td_sdk_purchase_inapp"; 27 | private static final String PURCHASE_SUBS_SHARED_PREF_NAME = 28 | "td_sdk_purchase_subs"; 29 | private static final SharedPreferences skuDetailSharedPrefs = 30 | TreasureData.getApplicationContext().getSharedPreferences(SKU_DETAILS_SHARED_PREF_NAME, Context.MODE_PRIVATE); 31 | private static final SharedPreferences purchaseInappSharedPrefs = 32 | TreasureData.getApplicationContext().getSharedPreferences(PURCHASE_INAPP_SHARED_PREF_NAME, Context.MODE_PRIVATE); 33 | private static final SharedPreferences purchaseSubsSharedPrefs = 34 | TreasureData.getApplicationContext().getSharedPreferences(PURCHASE_SUBS_SHARED_PREF_NAME, Context.MODE_PRIVATE); 35 | 36 | private static final int PURCHASE_EXPIRE_DURATION_SEC = 24 * 60 * 60; // 24 h 37 | 38 | // SKU detail cache setting 39 | private static final int SKU_DETAIL_EXPIRE_DURATION_SEC = 24 * 60 * 60; // 24 h 40 | private static final int SKU_DETAIL_CACHE_CLEAR_DURATION_SEC = 7 * 24 * 60 * 60; // 7 days 41 | 42 | private static final String SKU_DETAIL_LAST_CLEARED_TIME = "SKU_DETAIL_LAST_CLEARED_TIME"; 43 | 44 | private PurchaseEventManager() { 45 | 46 | } 47 | 48 | public static List getPurchasesInapp(Context context, Object inAppBillingObj) { 49 | 50 | return filterAndCachePurchasesInapp(BillingDelegate.getPurchases(context, inAppBillingObj, INAPP)); 51 | } 52 | 53 | public static List getPurchaseHistoryInapp(Context context, Object inAppBillingObj) { 54 | return filterAndCachePurchasesInapp(BillingDelegate.getPurchaseHistory(context, inAppBillingObj, INAPP)); 55 | } 56 | 57 | public static List getPurchasesSubs(Context context, Object inAppBillingObj) { 58 | 59 | return resolveAndCachePurchasesSubs(BillingDelegate.getPurchases(context, inAppBillingObj, SUBSCRIPTION)); 60 | } 61 | 62 | private static List filterAndCachePurchasesInapp(List purchases) { 63 | List filteredPurchases = new ArrayList<>(); 64 | SharedPreferences.Editor editor = purchaseInappSharedPrefs.edit(); 65 | long nowSec = System.currentTimeMillis() / 1000L; 66 | for (String purchase : purchases) { 67 | try { 68 | JSONObject purchaseJson = new JSONObject(purchase); 69 | String sku = purchaseJson.getString("productId"); 70 | long purchaseTimeMillis = purchaseJson.getLong("purchaseTime"); 71 | String purchaseToken = purchaseJson.getString("purchaseToken"); 72 | if (nowSec - purchaseTimeMillis / 1000L > PURCHASE_EXPIRE_DURATION_SEC) { 73 | continue; 74 | } 75 | 76 | String oldPurchaseToken = purchaseInappSharedPrefs.getString(sku, ""); 77 | 78 | if (oldPurchaseToken.equals(purchaseToken)) { 79 | continue; 80 | } 81 | 82 | // Write new purchase into cache 83 | editor.putString(sku, purchaseToken); 84 | filteredPurchases.add(new Purchase(purchase)); 85 | } catch (JSONException e) { 86 | Log.e(TAG, "Unable to parse purchase, not a json object: ", e); 87 | } 88 | } 89 | 90 | editor.apply(); 91 | 92 | return filteredPurchases; 93 | } 94 | 95 | private static List resolveAndCachePurchasesSubs(List purchases) { 96 | List resolvedPurchases = new ArrayList<>(); 97 | for (String purchase : purchases) { 98 | try { 99 | JSONObject purchaseJson = new JSONObject(purchase); 100 | String sku = purchaseJson.getString("productId"); 101 | String purchaseToken = purchaseJson.getString("purchaseToken"); 102 | 103 | String oldPurchase = purchaseSubsSharedPrefs.getString(sku, ""); 104 | JSONObject oldPurchaseJson = oldPurchase.isEmpty() 105 | ? new JSONObject() : new JSONObject(oldPurchase); 106 | String oldPurchaseToken = oldPurchaseJson.optString("purchaseToken"); 107 | 108 | Purchase.SubscriptionStatus subscriptionStatus = null; 109 | 110 | if (!oldPurchaseToken.equals(purchaseToken)) { 111 | // New purchase is always true for autoRenewing 112 | if (!purchaseJson.getBoolean("autoRenewing")) { 113 | continue; 114 | } 115 | subscriptionStatus = Purchase.SubscriptionStatus.New; 116 | }else if (!oldPurchase.isEmpty()) { 117 | boolean oldAutoRenewing = oldPurchaseJson.getBoolean("autoRenewing"); 118 | boolean newAutoRenewing = purchaseJson.getBoolean("autoRenewing"); 119 | 120 | if (!newAutoRenewing && oldAutoRenewing) { 121 | subscriptionStatus = Purchase.SubscriptionStatus.Cancelled; 122 | } else if (!oldAutoRenewing && newAutoRenewing) { 123 | subscriptionStatus = Purchase.SubscriptionStatus.Restored; 124 | } else { // newAutoRenewing == oldAutoRenewing, tracked already 125 | continue; 126 | } 127 | } 128 | 129 | resolvedPurchases.add(new Purchase(purchase, subscriptionStatus)); 130 | 131 | purchaseSubsSharedPrefs.edit().putString(sku, purchase).apply(); 132 | } catch (JSONException e) { 133 | Log.e(TAG, "Unable to parse purchase, not a json object: ", e); 134 | } 135 | } 136 | 137 | // SubscriptionStatus.Expired 138 | resolvedPurchases.addAll(getExpiredPurchaseSubs(purchases)); 139 | return resolvedPurchases; 140 | } 141 | 142 | private static List getExpiredPurchaseSubs(List currentPurchases) { 143 | List expiredPurchases = new ArrayList<>(); 144 | Map keys = purchaseSubsSharedPrefs.getAll(); 145 | 146 | if (keys.isEmpty()) { 147 | return expiredPurchases; 148 | } 149 | 150 | Set currSkuSet = new HashSet<>(); 151 | for (String purchase : currentPurchases) { 152 | try { 153 | JSONObject purchaseJson = new JSONObject(purchase); 154 | currSkuSet.add(purchaseJson.getString("productId")); 155 | } catch (JSONException e) { 156 | Log.e(TAG, "Unable to parse purchase, not a json object:", e); 157 | } 158 | } 159 | 160 | Set expiredSkus = new HashSet<>(); 161 | for (Map.Entry entry : keys.entrySet()){ 162 | String sku = entry.getKey(); 163 | if (!currSkuSet.contains(sku)) { 164 | expiredSkus.add(sku); 165 | } 166 | } 167 | 168 | SharedPreferences.Editor editor = purchaseSubsSharedPrefs.edit(); 169 | for (String expiredSku : expiredSkus) { 170 | String expiredPurchase = purchaseSubsSharedPrefs.getString(expiredSku, ""); 171 | 172 | // Do not need to cache expired purchase any more 173 | editor.remove(expiredSku); 174 | 175 | if (!expiredPurchase.isEmpty()) { 176 | expiredPurchases.add(new Purchase(expiredPurchase, Purchase.SubscriptionStatus.Expired)); 177 | } 178 | } 179 | editor.apply(); 180 | 181 | return expiredPurchases; 182 | } 183 | 184 | public static Map getAndCacheSkuDetails( 185 | Context context, Object inAppBillingObj, List skuList, String type) { 186 | 187 | Map skuDetailsMap = readSkuDetailsFromCache(skuList); 188 | 189 | ArrayList newSkuList = new ArrayList<>(); 190 | for (String sku : skuList) { 191 | if (!skuDetailsMap.containsKey(sku)) { 192 | newSkuList.add(sku); 193 | } 194 | } 195 | 196 | skuDetailsMap.putAll(BillingDelegate.getSkuDetails( 197 | context, inAppBillingObj, newSkuList, type)); 198 | writeSkuDetailsToCache(skuDetailsMap); 199 | 200 | return skuDetailsMap; 201 | } 202 | 203 | private static Map readSkuDetailsFromCache( 204 | List skuList) { 205 | 206 | Map skuDetailsMap = new HashMap<>(); 207 | long nowSec = System.currentTimeMillis() / 1000L; 208 | 209 | for (String sku : skuList) { 210 | String rawString = skuDetailSharedPrefs.getString(sku, null); 211 | if (rawString != null) { 212 | String[] splitted = rawString.split(";", 2); 213 | long timeSec = Long.parseLong(splitted[0]); 214 | if (nowSec - timeSec < SKU_DETAIL_EXPIRE_DURATION_SEC) { 215 | skuDetailsMap.put(sku, splitted[1]); 216 | } 217 | } 218 | } 219 | 220 | return skuDetailsMap; 221 | } 222 | 223 | private static void writeSkuDetailsToCache(Map skuDetailsMap) { 224 | long nowSec = System.currentTimeMillis() / 1000L; 225 | 226 | SharedPreferences.Editor editor = skuDetailSharedPrefs.edit(); 227 | for (Map.Entry entry : skuDetailsMap.entrySet()) { 228 | editor.putString(entry.getKey(), nowSec + ";" + entry.getValue()); 229 | } 230 | 231 | editor.apply(); 232 | } 233 | 234 | public static void clearAllSkuDetailsCache() { 235 | long nowSec = System.currentTimeMillis() / 1000L; 236 | 237 | // Sku details cache 238 | long lastClearedTimeSec = skuDetailSharedPrefs.getLong(SKU_DETAIL_LAST_CLEARED_TIME, 0); 239 | if (lastClearedTimeSec == 0) { 240 | skuDetailSharedPrefs.edit() 241 | .putLong(SKU_DETAIL_LAST_CLEARED_TIME, nowSec) 242 | .apply(); 243 | } else if ((nowSec - lastClearedTimeSec) > SKU_DETAIL_CACHE_CLEAR_DURATION_SEC) { 244 | skuDetailSharedPrefs.edit() 245 | .clear() 246 | .putLong(SKU_DETAIL_LAST_CLEARED_TIME, nowSec) 247 | .apply(); 248 | } 249 | } 250 | } 251 | -------------------------------------------------------------------------------- /src/main/java/com/treasuredata/android/cdp/CDPAPIException.java: -------------------------------------------------------------------------------- 1 | package com.treasuredata.android.cdp; 2 | 3 | import org.json.JSONException; 4 | import org.json.JSONObject; 5 | 6 | @SuppressWarnings("WeakerAccess") 7 | public class CDPAPIException extends Exception { 8 | 9 | private int status; 10 | private String error; 11 | 12 | /** 13 | * @param status Error JSON response's status property or HTTP status code 14 | * (the earlier should be preferred if exists) 15 | * @param error is nullable, in case the HTTP response body is not a JSON 16 | * @param message, either the `message` property in response JSON, or the entire body 17 | */ 18 | CDPAPIException(int status, String error, String message) { 19 | super(message); 20 | this.status = status; 21 | this.error = error; 22 | } 23 | 24 | /** 25 | * @return Original "error" property from the responded error JSON from server, 26 | * or null be null if the response body is not a JSON 27 | **/ 28 | public String getError() { 29 | return error; 30 | } 31 | 32 | /** 33 | * @return "status" property from the error JSON response, 34 | * or the actual HTTP Status Code responded from server if response body is not a JSON 35 | */ 36 | public int getStatus() { 37 | return status; 38 | } 39 | 40 | /** 41 | * Will attempt to treat body as a JSON first, 42 | * if it's not then use the entire body as the message. 43 | **/ 44 | static CDPAPIException from(int statusCode, String body) { 45 | try { 46 | return CDPAPIException.from(statusCode, new JSONObject(body)); 47 | } catch (JSONException e) { 48 | // Ignore 49 | } 50 | return new CDPAPIException(statusCode, null, body); 51 | } 52 | 53 | /** 54 | * @param statusCode responded HTTP status code, 55 | * but only be used of json doesn't contain a 56 | * "status" property. 57 | * @param json Error body response from CDP API, 58 | * the return exception wil prefer "status" property 59 | * inside this result if it exists, otherwise use 60 | * the provided status parameter. 61 | */ 62 | static CDPAPIException from(int statusCode, JSONObject json) { 63 | if (json == null) { 64 | return new CDPAPIException(statusCode, null, null); 65 | } 66 | String error = json.optString("error", null); 67 | String message = json.optString("message", null); 68 | int status = json.optInt("status", statusCode); 69 | 70 | if (error == null && message == null) { 71 | // Hopefully doesn't happen, but if the received JSON Object contains unexpected schema, 72 | // then use the entire JSON as the exception message. 73 | return new CDPAPIException(status, null, json.toString()); 74 | } else { 75 | return new CDPAPIException(status, error, message); 76 | } 77 | } 78 | 79 | } 80 | -------------------------------------------------------------------------------- /src/main/java/com/treasuredata/android/cdp/CDPClient.java: -------------------------------------------------------------------------------- 1 | package com.treasuredata.android.cdp; 2 | 3 | import java.util.List; 4 | import java.util.Map; 5 | 6 | /** 7 | * A single-purpose client for now, 8 | * Use to lookup for CDP's Profiles 9 | */ 10 | public interface CDPClient { 11 | 12 | /** 13 | * @param profilesTokens list of Profile API Token that are defined on TreasureData 14 | * @param keys lookup keyColumn values 15 | * @param callback to receive the looked-up result 16 | */ 17 | void fetchUserSegments(final List profilesTokens, 18 | final Map keys, 19 | final FetchUserSegmentsCallback callback); 20 | 21 | } 22 | -------------------------------------------------------------------------------- /src/main/java/com/treasuredata/android/cdp/CDPClientImpl.java: -------------------------------------------------------------------------------- 1 | package com.treasuredata.android.cdp; 2 | 3 | import android.os.Handler; 4 | import android.os.Looper; 5 | 6 | import java.io.IOException; 7 | import java.io.InputStream; 8 | import java.io.UnsupportedEncodingException; 9 | import java.net.HttpURLConnection; 10 | import java.net.URI; 11 | import java.net.URISyntaxException; 12 | import java.net.URLEncoder; 13 | import java.util.ArrayList; 14 | import java.util.HashMap; 15 | import java.util.List; 16 | import java.util.Map; 17 | import java.util.concurrent.ExecutorService; 18 | 19 | import static android.os.Looper.getMainLooper; 20 | import static android.os.Looper.myLooper; 21 | import static android.text.TextUtils.join; 22 | import static io.keen.client.java.KeenUtils.convertStreamToString; 23 | import static java.util.concurrent.Executors.newFixedThreadPool; 24 | 25 | public class CDPClientImpl implements CDPClient { 26 | private static final int CONNECT_TIMEOUT = 15000; 27 | private static final int READ_TIMEOUT = 60000; 28 | 29 | private static final URI DEFAULT_ENDPOINT; 30 | 31 | static { 32 | try { 33 | DEFAULT_ENDPOINT = new URI("https://cdp.in.treasuredata.com"); 34 | } catch (URISyntaxException e) { 35 | // Should not ever happen 36 | throw new IllegalStateException(e); 37 | } 38 | } 39 | 40 | private final URI apiURI; 41 | private final ExecutorService executor; 42 | 43 | public CDPClientImpl() { 44 | this(DEFAULT_ENDPOINT); 45 | } 46 | 47 | public CDPClientImpl(String endpoint) throws URISyntaxException { 48 | this(new URI(endpoint)); 49 | } 50 | 51 | public CDPClientImpl(URI endpoint) { 52 | // Could be opened for number of threads customization later 53 | this(endpoint, newFixedThreadPool(1)); 54 | } 55 | 56 | private CDPClientImpl(URI endpoint, ExecutorService executor) { 57 | this.apiURI = endpoint.resolve("/cdp/lookup/collect/segments"); 58 | this.executor = executor; 59 | } 60 | 61 | public void fetchUserSegments(final List profilesTokens, 62 | final Map keys, 63 | final FetchUserSegmentsCallback callback) { 64 | if (profilesTokens == null) throw new NullPointerException("`profileAPITokens` is required!"); 65 | if (keys == null) throw new NullPointerException("`keys` is required!"); 66 | if (callback == null) throw new NullPointerException("`callback` is required"); 67 | 68 | // Copy parameters to avoid concurrent modifications from upstream 69 | final ArrayList profileTokensSafeCopy = new ArrayList<>(profilesTokens); 70 | final HashMap keysSafeCopy = new HashMap<>(keys); 71 | 72 | // If current thread is associated with a looper, 73 | // then use that for the callback invocation, use main loop otherwise. 74 | final Looper callbackLooper = myLooper() != null ? myLooper() : getMainLooper(); 75 | 76 | executor.execute(new Runnable() { 77 | @Override 78 | public void run() { 79 | final FetchUserSegmentsResult result = fetchUserSegmentResultSynchronously(profileTokensSafeCopy, keysSafeCopy); 80 | 81 | if (callbackLooper != null) { 82 | new Handler(callbackLooper).post(new Runnable() { 83 | @Override 84 | public void run() { 85 | result.invoke(callback); 86 | } 87 | }); 88 | } else { 89 | // In any case where even mainLooper is null (using on an non-Android runtime?), 90 | // just do the callback on this thread. 91 | result.invoke(callback); 92 | } 93 | } 94 | }); 95 | } 96 | 97 | // Visible for testing 98 | FetchUserSegmentsResult fetchUserSegmentResultSynchronously(final List profilesTokens, final Map keys) { 99 | HttpURLConnection connection = null; 100 | try { 101 | connection = (HttpURLConnection) apiURI 102 | .resolve(makeQueryString(profilesTokens, keys)) 103 | .toURL().openConnection(); 104 | connection.setRequestMethod("GET"); 105 | 106 | connection.setConnectTimeout(CONNECT_TIMEOUT); 107 | connection.setReadTimeout(READ_TIMEOUT); 108 | 109 | int responseCode = connection.getResponseCode(); 110 | try (InputStream is = connection.getInputStream()) { 111 | return FetchUserSegmentsResult.create(responseCode, convertStreamToString(is)); 112 | } 113 | } catch (IOException e) { 114 | return FetchUserSegmentsResult.create(e); 115 | } finally { 116 | if (connection != null) connection.disconnect(); 117 | } 118 | } 119 | 120 | private static String makeQueryString(List profileTokens, Map keys) { 121 | return makeQueryString(makeParameters(profileTokens, keys)); 122 | } 123 | 124 | private static String makeQueryString(Map parameters) { 125 | List urlEncodedEntries = new ArrayList<>(); 126 | try { 127 | for (Map.Entry param : parameters.entrySet()) { 128 | urlEncodedEntries.add( 129 | URLEncoder.encode(param.getKey(), "UTF-8") 130 | + "=" + URLEncoder.encode(param.getValue(), "UTF-8")); 131 | } 132 | } catch (UnsupportedEncodingException e) { 133 | // Should not happen, unless we're being on an archaic platform 134 | throw new RuntimeException(e); 135 | } 136 | return "?" + join("&", urlEncodedEntries); 137 | } 138 | 139 | private static Map makeParameters(List profileTokens, Map keys) { 140 | Map parameters = new HashMap<>(); 141 | parameters.put("version", "2"); 142 | parameters.put("token", join(",", profileTokens)); 143 | for (Map.Entry entry : keys.entrySet()) { 144 | parameters.put("key." + entry.getKey(), entry.getValue()); 145 | } 146 | return parameters; 147 | } 148 | 149 | } 150 | -------------------------------------------------------------------------------- /src/main/java/com/treasuredata/android/cdp/ErrorFetchUserSegmentsResult.java: -------------------------------------------------------------------------------- 1 | package com.treasuredata.android.cdp; 2 | 3 | class ErrorFetchUserSegmentsResult extends FetchUserSegmentsResult { 4 | 5 | private final Exception exception; 6 | 7 | ErrorFetchUserSegmentsResult(Exception e) { 8 | exception = e; 9 | } 10 | 11 | @Override 12 | void invoke(FetchUserSegmentsCallback callback) { 13 | callback.onError(exception); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/main/java/com/treasuredata/android/cdp/FetchUserSegmentsCallback.java: -------------------------------------------------------------------------------- 1 | package com.treasuredata.android.cdp; 2 | 3 | import java.util.List; 4 | 5 | public interface FetchUserSegmentsCallback { 6 | /** 7 | * Handle success looked up segments result 8 | * 9 | * @param profiles that matches with the specified query 10 | */ 11 | void onSuccess(List profiles); 12 | 13 | /** 14 | * Handle failure 15 | * 16 | * @param e the exception could be: 17 | * - {@link CDPAPIException}, 18 | * - {@link org.json.JSONException}, 19 | * or any unexpected upstream exception (IOException for example). 20 | */ 21 | void onError(Exception e); 22 | 23 | } 24 | -------------------------------------------------------------------------------- /src/main/java/com/treasuredata/android/cdp/FetchUserSegmentsResult.java: -------------------------------------------------------------------------------- 1 | package com.treasuredata.android.cdp; 2 | 3 | import org.json.JSONException; 4 | 5 | abstract class FetchUserSegmentsResult { 6 | 7 | abstract void invoke(FetchUserSegmentsCallback callback); 8 | 9 | static FetchUserSegmentsResult create(Exception exception) { 10 | return new ErrorFetchUserSegmentsResult(exception); 11 | } 12 | 13 | static FetchUserSegmentsResult create(int status, String body) { 14 | try { 15 | // Assume body is a JSON, if it gets throw then fallback into into (error) raw string body. 16 | return JSONFetchUserSegmentsResult.createJSONResult(status, body); 17 | } catch (JSONException | IllegalArgumentException e) { 18 | return ErrorFetchUserSegmentsResult.create(CDPAPIException.from(status, body)); 19 | } 20 | } 21 | 22 | } 23 | -------------------------------------------------------------------------------- /src/main/java/com/treasuredata/android/cdp/JSONFetchUserSegmentsResult.java: -------------------------------------------------------------------------------- 1 | package com.treasuredata.android.cdp; 2 | 3 | import org.json.JSONArray; 4 | import org.json.JSONException; 5 | import org.json.JSONObject; 6 | import org.json.JSONTokener; 7 | 8 | import java.util.ArrayList; 9 | import java.util.List; 10 | 11 | abstract class JSONFetchUserSegmentsResult extends FetchUserSegmentsResult { 12 | final int statusCode; 13 | 14 | JSONFetchUserSegmentsResult(int statusCode) { 15 | this.statusCode = statusCode; 16 | } 17 | 18 | /** 19 | * @throws JSONException if the provided body is not json 20 | * @throws IllegalArgumentException if the parsed json is neither object or array, or has unexpected scheme 21 | */ 22 | static FetchUserSegmentsResult createJSONResult(int status, String body) throws JSONException, IllegalArgumentException { 23 | Object json = new JSONTokener(body).nextValue(); 24 | if (status != 200) { 25 | // Immediate consider this is an error for non-200 status code, 26 | // try to extract for "error" and "message" in the response body 27 | try { 28 | return new JSONObjectFetchUserSegmentsResult(status, (JSONObject) json); 29 | } catch (ClassCastException e) { 30 | // If this is not a JSON object then just throw for the caller to handle 31 | throw new IllegalArgumentException(e); 32 | } 33 | } else if (json instanceof JSONObject) { 34 | return new JSONObjectFetchUserSegmentsResult(status, (JSONObject) json); 35 | } else if (json instanceof JSONArray) { 36 | return new JSONArrayFetchUserSegmentsResult(status, (JSONArray) json); 37 | } else { 38 | throw new IllegalArgumentException( 39 | "Expect either an JSON Object or Array while received: " + 40 | (json != null ? json.getClass() : "null")); 41 | } 42 | } 43 | 44 | /** 45 | * JSON Array responses are considered success, expect to be an array of Profiles 46 | */ 47 | private static class JSONArrayFetchUserSegmentsResult extends JSONFetchUserSegmentsResult { 48 | private final JSONArray json; 49 | 50 | JSONArrayFetchUserSegmentsResult(int responseCode, JSONArray json) { 51 | super(responseCode); 52 | this.json = json; 53 | } 54 | 55 | @Override 56 | void invoke(FetchUserSegmentsCallback callback) { 57 | List profiles = new ArrayList<>(); 58 | try { 59 | for (int i = 0; i < json.length(); i++) { 60 | profiles.add(ProfileImpl.fromJSONObject(json.getJSONObject(i))); 61 | } 62 | 63 | } catch (JSONException e) { 64 | callback.onError(e); 65 | return; 66 | } 67 | callback.onSuccess(profiles); 68 | } 69 | } 70 | 71 | /** 72 | * Error response, expect to be in the form of {"error":..., "message":..., "status":...} 73 | */ 74 | private static class JSONObjectFetchUserSegmentsResult extends JSONFetchUserSegmentsResult { 75 | private final JSONObject json; 76 | 77 | JSONObjectFetchUserSegmentsResult(int httpStatusCode, JSONObject json) { 78 | super(httpStatusCode); 79 | this.json = json; 80 | } 81 | 82 | @Override 83 | void invoke(FetchUserSegmentsCallback callback) { 84 | callback.onError(CDPAPIException.from(statusCode, json)); 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/main/java/com/treasuredata/android/cdp/Profile.java: -------------------------------------------------------------------------------- 1 | package com.treasuredata.android.cdp; 2 | 3 | import java.util.List; 4 | import java.util.Map; 5 | 6 | /** 7 | * Represent a profile in segments looked-up's result, 8 | */ 9 | public interface Profile { 10 | /** 11 | * @return Segment IDs where this profile belongs 12 | */ 13 | List getSegments(); 14 | 15 | /** 16 | * @return This profile's attributes 17 | */ 18 | Map getAttributes(); 19 | 20 | /** 21 | * @return Key columns : values of segments 22 | */ 23 | Key getKey(); 24 | 25 | /** 26 | * @return ID of the Master Segment 27 | */ 28 | String getAudienceId(); 29 | 30 | interface Key { 31 | 32 | /** 33 | * @return Name of key column 34 | */ 35 | String getName(); 36 | 37 | /** 38 | * @return Key value of the looked up profile 39 | */ 40 | Object getValue(); 41 | 42 | } 43 | 44 | } 45 | -------------------------------------------------------------------------------- /src/main/java/com/treasuredata/android/cdp/ProfileImpl.java: -------------------------------------------------------------------------------- 1 | package com.treasuredata.android.cdp; 2 | 3 | import com.fasterxml.jackson.jr.ob.JSON; 4 | import org.json.JSONArray; 5 | import org.json.JSONException; 6 | import org.json.JSONObject; 7 | 8 | import java.io.IOException; 9 | import java.util.ArrayList; 10 | import java.util.Iterator; 11 | import java.util.List; 12 | import java.util.Map; 13 | 14 | class ProfileImpl implements Profile { 15 | private List segments; 16 | private Map attributes; 17 | private Key key; 18 | private String audienceId; 19 | 20 | private ProfileImpl(List segments, Map attributes, Key key, String audienceId) { 21 | this.segments = segments; 22 | this.attributes = attributes; 23 | this.key = key; 24 | this.audienceId = audienceId; 25 | } 26 | 27 | @Override 28 | public List getSegments() { 29 | return segments; 30 | } 31 | 32 | @Override 33 | public Map getAttributes() { 34 | return attributes; 35 | } 36 | 37 | @Override 38 | public Key getKey() { 39 | return key; 40 | } 41 | 42 | @Override 43 | public String getAudienceId() { 44 | return audienceId; 45 | } 46 | 47 | /** 48 | * Try to deserialize the provided json into a Profile object, 49 | * absent properties will be leaved as null. 50 | */ 51 | static ProfileImpl fromJSONObject(JSONObject profileJson) throws JSONException { 52 | List segments = null; 53 | if (profileJson.has("values")) { 54 | segments = new ArrayList<>(); 55 | JSONArray segmentsJson = profileJson.getJSONArray("values"); 56 | for (int i = 0; i < segmentsJson.length(); i++) { 57 | segments.add(segmentsJson.getString(i)); 58 | } 59 | } 60 | 61 | Map attributes = null; 62 | if (profileJson.has("attributes")) { 63 | JSONObject attributesJson = profileJson.getJSONObject("attributes"); 64 | try { 65 | // Unfortunately, org.json doesn't allow deserialize to objects 66 | attributes = JSON.std.mapFrom(attributesJson.toString()); 67 | } catch (IOException e) { 68 | // Wrapping JSONException onto a different original cause is not support until Android API level 27 69 | throw new JSONException(e.getMessage()); 70 | } 71 | } 72 | 73 | Key key = null; 74 | if (profileJson.has("key")) { 75 | JSONObject keyJson = profileJson.getJSONObject("key"); 76 | 77 | // Expect keyJson to be a single property JSON object 78 | Iterator keys = keyJson.keys(); 79 | if (keys.hasNext()) { 80 | Object keyProp = keys.next(); 81 | if (!(keyProp instanceof String)) { 82 | throw new JSONException("Expect `key` to be a map of "); 83 | } 84 | String keyName = (String) keyProp; 85 | key = new KeyImpl(keyName, keyJson.get(keyName)); 86 | } 87 | } 88 | 89 | String audienceId = profileJson.optString("audienceId", null); 90 | 91 | return new ProfileImpl(segments, attributes, key, audienceId); 92 | } 93 | 94 | private static class KeyImpl implements Key { 95 | private String name; 96 | private Object value; 97 | 98 | KeyImpl(String name, Object value) { 99 | this.name = name; 100 | this.value = value; 101 | } 102 | 103 | @Override 104 | public String getName() { 105 | return name; 106 | } 107 | 108 | @Override 109 | public Object getValue() { 110 | return value; 111 | } 112 | } 113 | 114 | } 115 | -------------------------------------------------------------------------------- /src/test/java/com/treasuredata/android/SessionTest.java: -------------------------------------------------------------------------------- 1 | package com.treasuredata.android; 2 | 3 | import org.junit.Test; 4 | 5 | import java.util.concurrent.TimeUnit; 6 | 7 | import static org.junit.Assert.*; 8 | 9 | public class SessionTest { 10 | @Test 11 | public void getIdReturnsNullWithoutStart() throws InterruptedException { 12 | Session session = new Session(1000); 13 | assertNull(session.getId()); 14 | } 15 | 16 | @Test 17 | public void startShouldActivateId() throws InterruptedException { 18 | Session session = new Session(1000); 19 | 20 | session.start(); 21 | String firstSessionId = session.getId(); 22 | assertNotNull(firstSessionId); 23 | 24 | String sessionId = session.getId(); 25 | assertNotNull(sessionId); 26 | assertEquals(firstSessionId, sessionId); 27 | } 28 | 29 | @Test 30 | public void finishShouldInactivateId() throws InterruptedException { 31 | Session session = new Session(1000); 32 | session.start(); 33 | session.finish(); 34 | assertNull(session.getId()); 35 | } 36 | 37 | @Test 38 | public void reStartWithinIntervalShouldReuseId() throws InterruptedException { 39 | Session session = new Session(1000); 40 | 41 | session.start(); 42 | String firstSessionId = session.getId(); 43 | assertNotNull(firstSessionId); 44 | session.finish(); 45 | 46 | session.start(); 47 | String secondSessionId = session.getId(); 48 | assertNotNull(secondSessionId); 49 | assertEquals(firstSessionId, secondSessionId); 50 | } 51 | 52 | @Test 53 | public void reStartAfterExpirationShouldNotReuseId() throws InterruptedException { 54 | Session session = new Session(500); 55 | 56 | session.start(); 57 | String firstSessionId = session.getId(); 58 | assertNotNull(firstSessionId); 59 | session.finish(); 60 | 61 | TimeUnit.MILLISECONDS.sleep(1000); 62 | 63 | session.start(); 64 | String secondSessionId = session.getId(); 65 | assertNotNull(secondSessionId); 66 | assertNotEquals(firstSessionId, secondSessionId); 67 | } 68 | 69 | @Test 70 | public void reStartWithoutFinishShouldNotUpdateId() throws InterruptedException { 71 | Session session = new Session(1000); 72 | 73 | session.start(); 74 | String firstSessionId = session.getId(); 75 | assertNotNull(firstSessionId); 76 | 77 | session.start(); 78 | String secondSessionId = session.getId(); 79 | assertNotNull(secondSessionId); 80 | 81 | assertEquals(firstSessionId, secondSessionId); 82 | } 83 | } -------------------------------------------------------------------------------- /src/test/java/com/treasuredata/android/TDClientTest.java: -------------------------------------------------------------------------------- 1 | package com.treasuredata.android; 2 | 3 | import com.fasterxml.jackson.jr.ob.JSON; 4 | import io.keen.client.java.KeenCallback; 5 | import okhttp3.mockwebserver.MockResponse; 6 | import okhttp3.mockwebserver.MockWebServer; 7 | import okhttp3.mockwebserver.RecordedRequest; 8 | 9 | import org.junit.After; 10 | import org.junit.Before; 11 | import org.junit.Rule; 12 | import org.junit.Test; 13 | import org.junit.rules.TemporaryFolder; 14 | 15 | import java.io.File; 16 | import java.io.IOException; 17 | import java.util.ArrayList; 18 | import java.util.Arrays; 19 | import java.util.Collections; 20 | import java.util.Comparator; 21 | import java.util.HashMap; 22 | import java.util.List; 23 | import java.util.Map; 24 | import java.util.concurrent.CountDownLatch; 25 | import java.util.concurrent.TimeUnit; 26 | 27 | import static org.hamcrest.core.Is.is; 28 | import static org.junit.Assert.*; 29 | 30 | public class TDClientTest 31 | { 32 | private final static String APIKEY = "9999/1qaz2wsx3edc4rfv5tgb6yhn"; 33 | private final static JSON JSON = new JSON(); 34 | 35 | @Rule 36 | public TemporaryFolder temporaryFolder = new TemporaryFolder(); 37 | 38 | private File cacheDir; 39 | private MockWebServer server; 40 | 41 | @Before 42 | public void setUp() 43 | throws IOException 44 | { 45 | cacheDir = temporaryFolder.getRoot(); 46 | 47 | server = new MockWebServer(); 48 | 49 | TDHttpHandler.disableEventCompression(); 50 | } 51 | 52 | @After 53 | public void tearDown() 54 | throws IOException 55 | { 56 | TDHttpHandler.enableEventCompression(); 57 | 58 | server.shutdown(); 59 | } 60 | 61 | private void sendQueuedEventsAndAssert(final TDClient client, final List>>> expects) 62 | throws Exception { 63 | final CountDownLatch latch = new CountDownLatch(1); 64 | client.sendQueuedEventsAsync(null, new KeenCallback() { 65 | @Override 66 | public void onSuccess() { 67 | try { 68 | Map>> eventMap = new HashMap<>(); 69 | 70 | for (int i = 0; i < server.getRequestCount(); i++) { 71 | RecordedRequest recordedRequest = server.takeRequest(); 72 | assertThat(recordedRequest.getMethod(), is("POST")); 73 | assertThat(recordedRequest.getHeader("Authorization"), is("TD1 " + APIKEY)); 74 | Map requests = JSON.mapFrom(recordedRequest.getBody().inputStream()); 75 | String[] pathComponents = recordedRequest.getPath().split("/"); 76 | String table = pathComponents[1].replace("/", "") + "." + pathComponents[2].replace("/", ""); 77 | 78 | List> events = (List>) requests.get("events"); 79 | if (eventMap.get(table) == null) { 80 | eventMap.put(table, new ArrayList>()); 81 | } 82 | eventMap.get(table).addAll(events); 83 | } 84 | 85 | int expectedRequestCount = 0; 86 | for (Map>> expected : expects) { 87 | for (String table : expected.keySet()) { 88 | List> expectedEvents = expected.get(table); 89 | expectedRequestCount += expectedEvents.size() % client.getMaxUploadEventsAtOnce() == 0 90 | ? expectedEvents.size() / client.getMaxUploadEventsAtOnce() 91 | : expectedEvents.size() / client.getMaxUploadEventsAtOnce() + 1; 92 | Collections.sort(expectedEvents, new Comparator>() { 93 | @Override 94 | public int compare(Map o1, Map o2) { 95 | return o1.get("name").toString().compareTo(o2.get("name").toString()); 96 | } 97 | }); 98 | List> actualEvents = eventMap.get(table); 99 | Collections.sort(actualEvents, new Comparator>() { 100 | @Override 101 | public int compare(Map o1, Map o2) { 102 | return o1.get("name").toString().compareTo(o2.get("name").toString()); 103 | } 104 | }); 105 | assertThat(actualEvents.size(), is(expectedEvents.size())); 106 | int i = 0; 107 | for (Map expectedEvent : expectedEvents) { 108 | Map actualEvent = actualEvents.get(i); 109 | for (Map.Entry keyAndValue : expectedEvent.entrySet()) { 110 | assertThat(actualEvent.get(keyAndValue.getKey()), is(keyAndValue.getValue())); 111 | } 112 | i++; 113 | } 114 | } 115 | } 116 | 117 | assertThat("Number of request made", server.getRequestCount(), is(expectedRequestCount)); 118 | latch.countDown(); 119 | } catch (Exception e) { 120 | e.printStackTrace(); 121 | } 122 | } 123 | 124 | @Override 125 | public void onFailure(Exception e) { 126 | e.printStackTrace(); 127 | assertTrue(false); 128 | } 129 | }); 130 | assertTrue(latch.await(10, TimeUnit.SECONDS)); 131 | } 132 | 133 | @Test 134 | public void sendToSingleTable() 135 | throws Exception 136 | { 137 | server.enqueue(new MockResponse().setBody("{\"receipts\":[{\"success\":true},{\"success\":true}]}")); 138 | server.start(); 139 | String apiEndpoint = String.format("http://127.0.0.1:%d", server.getPort()); 140 | TDClient client = new TDClient(APIKEY, apiEndpoint, cacheDir); 141 | 142 | HashMap event0 = new HashMap(); 143 | event0.put("name", "Foo"); 144 | event0.put("age", 42); 145 | client.queueEvent("db0.tbl0", event0); 146 | 147 | HashMap event1 = new HashMap(); 148 | event1.put("name", "Bar"); 149 | event1.put("age", 99); 150 | client.queueEvent("db0.tbl0", event1); 151 | 152 | Map>> expected = new HashMap>>(); 153 | expected.put("db0.tbl0", Arrays.>asList(event0, event1)); 154 | 155 | sendQueuedEventsAndAssert(client, Arrays.asList(expected)); 156 | } 157 | 158 | @Test 159 | public void sendToTwoTables() 160 | throws Exception 161 | { 162 | server.enqueue(new MockResponse().setBody("{\"receipts\":[{\"success\":true}]}")); 163 | server.enqueue(new MockResponse().setBody("{\"receipts\":[{\"success\":true}]}")); 164 | server.start(); 165 | String apiEndpoint = String.format("http://127.0.0.1:%d", server.getPort()); 166 | TDClient client = new TDClient(APIKEY, apiEndpoint, cacheDir); 167 | 168 | HashMap event0 = new HashMap(); 169 | event0.put("name", "Foo"); 170 | event0.put("age", 42); 171 | client.queueEvent("db0.tbl0", event0); 172 | 173 | HashMap event1 = new HashMap(); 174 | event1.put("name", "Bar"); 175 | event1.put("age", 99); 176 | client.queueEvent("db1.tbl1", event1); 177 | 178 | Map>> expected = new HashMap>>(); 179 | expected.put("db0.tbl0", Arrays.>asList(event0)); 180 | expected.put("db1.tbl1", Arrays.>asList(event1)); 181 | 182 | sendQueuedEventsAndAssert(client, Arrays.asList(expected)); 183 | } 184 | 185 | @Test 186 | public void sendToSingleTableWithLimitedUploadedEvents() 187 | throws Exception 188 | { 189 | server.enqueue(new MockResponse().setBody("{\"receipts\":[{\"success\":true},{\"success\":true},{\"success\":true}]}")); 190 | server.enqueue(new MockResponse().setBody("{\"receipts\":[{\"success\":true}]}")); 191 | server.start(); 192 | String apiEndpoint = String.format("http://127.0.0.1:%d", server.getPort()); 193 | TDClient client = new TDClient(APIKEY, apiEndpoint, cacheDir); 194 | client.setMaxUploadEventsAtOnce(3); 195 | 196 | HashMap event0 = new HashMap(); 197 | event0.put("name", "Foo"); 198 | event0.put("age", 42); 199 | client.queueEvent("db0.tbl0", event0); 200 | 201 | HashMap event1 = new HashMap(); 202 | event1.put("name", "Bar"); 203 | event1.put("age", 99); 204 | client.queueEvent("db0.tbl0", event1); 205 | 206 | HashMap event2 = new HashMap(); 207 | event2.put("name", "Baz"); 208 | event2.put("age", 1); 209 | client.queueEvent("db0.tbl0", event2); 210 | 211 | HashMap event3 = new HashMap(); 212 | event3.put("name", "zzz"); 213 | event3.put("age", 111); 214 | client.queueEvent("db0.tbl0", event3); 215 | 216 | Map>> expected = new HashMap>>(); 217 | expected.put("db0.tbl0", Arrays.>asList(event0, event1, event2, event3)); 218 | 219 | sendQueuedEventsAndAssert(client, Arrays.asList(expected)); 220 | } 221 | 222 | @Test 223 | public void sendToTwoTablesWithLimitedUploadedEvents() 224 | throws Exception { 225 | server.start(); 226 | 227 | String apiEndpoint = String.format("http://127.0.0.1:%d", server.getPort()); 228 | TDClient client = new TDClient(APIKEY, apiEndpoint, cacheDir); 229 | client.setMaxUploadEventsAtOnce(2); 230 | 231 | HashMap event0 = new HashMap(); 232 | event0.put("name", "Foo"); 233 | event0.put("age", 42); 234 | client.queueEvent("db0.tbl0", event0); 235 | 236 | HashMap event1 = new HashMap(); 237 | event1.put("name", "Bar"); 238 | event1.put("age", 99); 239 | client.queueEvent("db0.tbl0", event1); 240 | 241 | HashMap event2 = new HashMap(); 242 | event2.put("name", "Baz"); 243 | event2.put("age", 1); 244 | client.queueEvent("db1.tbl1", event2); 245 | 246 | HashMap event3 = new HashMap(); 247 | event3.put("name", "Zzz"); 248 | event3.put("age", 111); 249 | client.queueEvent("db1.tbl1", event3); 250 | 251 | HashMap event4 = new HashMap(); 252 | event4.put("name", "YYY"); 253 | event4.put("age", 111); 254 | client.queueEvent("db1.tbl1", event4); 255 | 256 | Map>> expected0 = new HashMap>>(); 257 | expected0.put("db0.tbl0", Arrays.>asList(event0, event1)); 258 | 259 | Map>> expected1 = new HashMap>>(); 260 | expected1.put("db1.tbl1", Arrays.>asList(event2, event3, event4)); 261 | 262 | // We must get db0.tbl0 handle and get the number of handles from there 263 | // to mock the correct number of success responses for each db 264 | List db0Tbl0Handles = client.getEventStore() 265 | .getHandles(client.getDefaultProject().getProjectId(), 3) 266 | .get("db0.tbl0"); 267 | 268 | if (db0Tbl0Handles.size() == 2) { 269 | server.enqueue(new MockResponse().setBody("{\"receipts\":[{\"success\":true},{\"success\":true}]}")); 270 | server.enqueue(new MockResponse().setBody("{\"receipts\":[{\"success\":true},{\"success\":true}]}")); 271 | server.enqueue(new MockResponse().setBody("{\"receipts\":[{\"success\":true}]}")); 272 | } else { 273 | server.enqueue(new MockResponse().setBody("{\"receipts\":[{\"success\":true},{\"success\":true}]}")); 274 | server.enqueue(new MockResponse().setBody("{\"receipts\":[{\"success\":true}]}")); 275 | server.enqueue(new MockResponse().setBody("{\"receipts\":[{\"success\":true},{\"success\":true}]}")); 276 | } 277 | 278 | sendQueuedEventsAndAssert(client, Arrays.asList(expected0, expected1)); 279 | } 280 | } 281 | -------------------------------------------------------------------------------- /src/test/java/com/treasuredata/android/TDJsonHandlerTest.java: -------------------------------------------------------------------------------- 1 | package com.treasuredata.android; 2 | 3 | import com.fasterxml.jackson.jr.ob.JSON; 4 | import junit.framework.TestCase; 5 | import org.apache.commons.codec.binary.Base64; 6 | 7 | import java.io.ByteArrayInputStream; 8 | import java.io.ByteArrayOutputStream; 9 | import java.io.IOException; 10 | import java.io.InputStreamReader; 11 | import java.io.OutputStreamWriter; 12 | import java.io.StringReader; 13 | import java.io.StringWriter; 14 | import java.io.Writer; 15 | import java.net.MalformedURLException; 16 | import java.net.URI; 17 | import java.net.URL; 18 | import java.nio.charset.Charset; 19 | import java.text.SimpleDateFormat; 20 | import java.util.ArrayList; 21 | import java.util.Arrays; 22 | import java.util.Date; 23 | import java.util.HashMap; 24 | import java.util.List; 25 | import java.util.Map; 26 | 27 | public class TDJsonHandlerTest extends TestCase { 28 | private static final String JSON_STR = "{\n" + 29 | " \"name\":\"komamitsu\",\n" + 30 | " \"age\":123,\n" + 31 | " \"uuid\":\"2F1FCD4D-74A6-45EF-B9B0-CD82DE49BE69\"\n" + 32 | "}"; 33 | private TDJsonHandler jsonHandler = new TDJsonHandler(); 34 | private JSON json = new CustomizedJSON(); 35 | private TDJsonHandler encJsonHandler = new TDJsonHandler("hello, world", new TDJsonHandler.Base64Encoder() { 36 | private final Charset UTF8 = Charset.forName("UTF-8"); 37 | @Override 38 | public String encode(byte[] data) 39 | { 40 | return new String(Base64.encodeBase64(data), UTF8); 41 | } 42 | 43 | @Override 44 | public byte[] decode(String encoded) 45 | { 46 | return Base64.decodeBase64(encoded.getBytes(UTF8)); 47 | } 48 | }); 49 | 50 | public void testReadJson() throws Exception { 51 | Map result = jsonHandler.readJson(new StringReader(JSON_STR)); 52 | assertEquals("komamitsu", result.get("name")); 53 | assertEquals(123, result.get("age")); 54 | assertEquals("2F1FCD4D-74A6-45EF-B9B0-CD82DE49BE69", result.get("uuid")); 55 | } 56 | 57 | private Map createExampleMap(Date now) 58 | throws MalformedURLException 59 | { 60 | Map>> root = new HashMap>>(); 61 | Map value = new HashMap(); 62 | value.put("name", "komamitsu"); 63 | value.put("age", 123); 64 | Map keen = new HashMap(); 65 | keen.put("timestamp", "2014-12-31T23:59:01.123+0000"); 66 | value.put("now", now); 67 | value.put("f_nan", Float.NaN); 68 | value.put("f_pos_inf", Float.POSITIVE_INFINITY); 69 | value.put("f_neg_inf", Float.NEGATIVE_INFINITY); 70 | value.put("d_nan", Double.NaN); 71 | value.put("d_pos_inf", Double.POSITIVE_INFINITY); 72 | value.put("d_neg_inf", Double.NEGATIVE_INFINITY); 73 | value.put("uuid", "2F1FCD4D-74A6-45EF-B9B0-CD82DE49BE69"); 74 | value.put("url", new URL("https://github.com/FasterXML/jackson-jr?a=x&b=y")); 75 | value.put("uri", URI.create("https://github.com/FasterXML/jackson-jr?c=x&c=y")); 76 | root.put("testdb.testtbl", Arrays.asList(value)); 77 | 78 | return root; 79 | } 80 | 81 | private void assertExampleMap(Date now, Map root) { 82 | assertEquals(1, root.size()); 83 | Object rootValue = root.get("testdb.testtbl"); 84 | assertTrue(rootValue instanceof List); 85 | List> list = (List>) rootValue; 86 | assertEquals(1, list.size()); 87 | assertTrue(list.get(0) instanceof Map); 88 | 89 | Map value = list.get(0); 90 | assertEquals("komamitsu", value.get("name")); 91 | assertEquals(123, value.get("age")); 92 | assertEquals(new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSZ").format(now), value.get("now")); 93 | assertEquals(String.valueOf(Float.NaN), value.get("f_nan")); 94 | assertEquals(String.valueOf(Float.POSITIVE_INFINITY), value.get("f_pos_inf")); 95 | assertEquals(String.valueOf(Float.NEGATIVE_INFINITY), value.get("f_neg_inf")); 96 | assertEquals(String.valueOf(Double.NaN), value.get("d_nan")); 97 | assertEquals(String.valueOf(Double.POSITIVE_INFINITY), value.get("d_pos_inf")); 98 | assertEquals(String.valueOf(Double.NEGATIVE_INFINITY), value.get("d_neg_inf")); 99 | assertEquals("https://github.com/FasterXML/jackson-jr?a=x&b=y", value.get("url")); 100 | assertEquals("https://github.com/FasterXML/jackson-jr?c=x&c=y", value.get("uri")); 101 | assertEquals("2F1FCD4D-74A6-45EF-B9B0-CD82DE49BE69", value.get("uuid")); 102 | } 103 | 104 | public void testWriteJson() throws Exception { 105 | Date now = new Date(); 106 | Map value = createExampleMap(now); 107 | 108 | ByteArrayOutputStream out = new ByteArrayOutputStream(); 109 | Writer writer = new OutputStreamWriter(out); 110 | jsonHandler.writeJson(writer, value); 111 | 112 | String s = new String(out.toByteArray()); 113 | Map result = json.mapFrom(s); 114 | assertExampleMap(now, result); 115 | } 116 | 117 | public void testReadWriteWithEncryption() throws IOException { 118 | Date now = new Date(); 119 | Map value = createExampleMap(now); 120 | 121 | StringWriter writer = new StringWriter(); 122 | encJsonHandler.writeJson(writer, value); 123 | 124 | Map result = encJsonHandler.readJson(new StringReader(writer.toString())); 125 | assertExampleMap(now, result); 126 | } 127 | 128 | public void testLargeData() throws IOException { 129 | int tag_num = 8; 130 | Map records = new HashMap(); 131 | StringBuilder sb = new StringBuilder(); 132 | for (int i = 0; i < 512; i++) { 133 | sb.append('x'); 134 | } 135 | String value = sb.toString(); 136 | for (int tag_idx = 0; tag_idx < tag_num; tag_idx++) { 137 | List> events = new ArrayList>(); 138 | for (int i = 0; i < 200; i++) { 139 | Map map = new HashMap(); 140 | for (int item_idx = 0; item_idx < 10; item_idx++) { 141 | map.put("key" + item_idx, value); 142 | } 143 | events.add(map); 144 | } 145 | records.put("tag" + tag_idx, events); 146 | } 147 | 148 | { 149 | ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); 150 | jsonHandler.writeJson(new OutputStreamWriter(outputStream), records); 151 | Map result = jsonHandler.readJson(new InputStreamReader(new ByteArrayInputStream(outputStream.toByteArray()))); 152 | assertEquals(tag_num, result.size()); 153 | } 154 | 155 | { 156 | ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); 157 | encJsonHandler.writeJson(new OutputStreamWriter(outputStream), records); 158 | Map result = encJsonHandler.readJson(new InputStreamReader(new ByteArrayInputStream(outputStream.toByteArray()))); 159 | assertEquals(tag_num, result.size()); 160 | } 161 | } 162 | } -------------------------------------------------------------------------------- /src/test/java/com/treasuredata/android/TDLoggingTest.java: -------------------------------------------------------------------------------- 1 | package com.treasuredata.android; 2 | 3 | import org.junit.After; 4 | import org.junit.Test; 5 | 6 | import static org.junit.Assert.assertFalse; 7 | import static org.junit.Assert.assertTrue; 8 | 9 | public class TDLoggingTest { 10 | @After 11 | public void tearDown() { 12 | TDLogging.disableLogging(); 13 | } 14 | 15 | @Test 16 | public void testEnableLoggingDisableLogging() { 17 | TDLogging.enableLogging(); 18 | assertTrue(TDLogging.isInitialized()); 19 | assertTrue(TDLogging.isEnabled()); 20 | 21 | TDLogging.disableLogging(); 22 | assertTrue(TDLogging.isInitialized()); 23 | assertFalse(TDLogging.isEnabled()); 24 | 25 | TDLogging.enableLogging(); 26 | assertTrue(TDLogging.isInitialized()); 27 | assertTrue(TDLogging.isEnabled()); 28 | } 29 | } -------------------------------------------------------------------------------- /src/test/java/com/treasuredata/android/cdp/CDPAPIExceptionTest.java: -------------------------------------------------------------------------------- 1 | package com.treasuredata.android.cdp; 2 | 3 | import org.junit.Test; 4 | 5 | import static org.junit.Assert.*; 6 | 7 | public class CDPAPIExceptionTest { 8 | 9 | @Test 10 | public void typical_error_json() { 11 | CDPAPIException e = CDPAPIException.from( 12 | 200, 13 | "{\"error\": \"Bad Request\", \"message\": \"you did xyz wrong\", \"status\": 400}"); 14 | 15 | // Actual HTTP status code (200) is ignored, 16 | // since CDP API server always response 200 except IO errors 17 | assertEquals(400, e.getStatus()); 18 | assertEquals("Bad Request", e.getError()); 19 | assertEquals("you did xyz wrong", e.getMessage()); 20 | } 21 | 22 | @Test 23 | public void if_status_property_is_missing_then_use_http_status_code() { 24 | CDPAPIException e = CDPAPIException.from( 25 | 200, 26 | "{\"error\": \"Bad Request\", \"message\": \"you did xyz wrong\"}"); 27 | 28 | assertEquals(200, e.getStatus()); 29 | assertEquals("Bad Request", e.getError()); 30 | assertEquals("you did xyz wrong", e.getMessage()); 31 | } 32 | 33 | @Test 34 | public void non_json_body() { 35 | CDPAPIException e = CDPAPIException.from( 36 | 401, 37 | ""); 38 | 39 | assertEquals(401, e.getStatus()); 40 | assertNull("Bad Request", e.getError()); 41 | assertEquals("", e.getMessage()); 42 | } 43 | 44 | } -------------------------------------------------------------------------------- /src/test/java/com/treasuredata/android/cdp/FetchUserSegmentsResultTest.java: -------------------------------------------------------------------------------- 1 | package com.treasuredata.android.cdp; 2 | 3 | import org.json.JSONObject; 4 | import org.junit.Before; 5 | import org.junit.Test; 6 | 7 | import java.util.List; 8 | import java.util.concurrent.CountDownLatch; 9 | import java.util.concurrent.TimeUnit; 10 | 11 | import static org.junit.Assert.assertEquals; 12 | import static org.junit.Assert.assertTrue; 13 | import static org.junit.Assert.fail; 14 | 15 | public class FetchUserSegmentsResultTest { 16 | 17 | private CountDownLatch latch; 18 | 19 | @Before 20 | public void setup() { 21 | latch = new CountDownLatch(1); 22 | } 23 | 24 | private void await() throws InterruptedException { 25 | // Should be fast, we don't do IO in these tests 26 | latch.await(1, TimeUnit.SECONDS); 27 | } 28 | 29 | @Test 30 | public void should_success_upon_a_json_array() throws Exception { 31 | FetchUserSegmentsResult 32 | .create(200, "[" + 33 | " {" + 34 | " \"values\": [" + 35 | " \"123\"" + 36 | " ]," + 37 | " \"attributes\": {" + 38 | " \"x\": 1" + 39 | " }," + 40 | " \"key\": {" + 41 | " \"id\": \"abcd\"" + 42 | " }," + 43 | " \"audienceId\": \"234\"" + 44 | " }" + 45 | "]") 46 | .invoke(shouldSuccess); 47 | await(); 48 | } 49 | 50 | @Test 51 | public void should_success_upon_an_empty_json_array() throws Exception { 52 | FetchUserSegmentsResult.create(200, "[]").invoke(shouldSuccess); 53 | await(); 54 | } 55 | 56 | @Test 57 | public void should_fail_upon_an_json_object() throws Exception { 58 | FetchUserSegmentsResult 59 | .create(200, "{" + 60 | " \"error\": \"Bad Request\"," + 61 | " \"message\": \"Some elaboration\"," + 62 | " \"status\": 400" + 63 | " }") 64 | .invoke(shouldFailedWith(new CDPAPIException(400, "Bad Request", "Some elaboration"))); 65 | await(); 66 | } 67 | 68 | @Test 69 | public void should_fail_on_a_json_object_even_without_error_and_message() throws Exception { 70 | FetchUserSegmentsResult 71 | .create(401, "{}") 72 | .invoke(shouldFailedWith(new CDPAPIException(401, null, "{}"))); 73 | await(); 74 | } 75 | 76 | @Test 77 | public void should_fail_on_a_non_200_upon_arbitrary_body() throws Exception { 78 | FetchUserSegmentsResult 79 | .create(400, "") 80 | .invoke(shouldFailedWith(new CDPAPIException(400, null, ""))); 81 | await(); 82 | } 83 | 84 | @Test 85 | public void should_fail_on_a_non_200_even_upon_valid_json_array_body() throws Exception { 86 | String body = "[" + 87 | " {" + 88 | " \"values\": [" + 89 | " \"123\"" + 90 | " ]," + 91 | " \"attributes\": {" + 92 | " \"x\": 1" + 93 | " }," + 94 | " \"key\": {" + 95 | " \"id\": \"abcd\"" + 96 | " }," + 97 | " \"audienceId\": \"234\"" + 98 | " }" + 99 | "]"; 100 | FetchUserSegmentsResult 101 | .create(400, body) 102 | .invoke(shouldFailedWith(new CDPAPIException(400, null, body))); 103 | await(); 104 | } 105 | 106 | @Test 107 | public void should_fail_on_200_but_non_json_body() throws Exception { 108 | FetchUserSegmentsResult 109 | .create(200, "") 110 | .invoke(shouldFailedWith(new CDPAPIException(200, null, ""))); 111 | await(); 112 | } 113 | 114 | @Test 115 | public void should_fail_on_200_but_empty_body() throws Exception { 116 | FetchUserSegmentsResult 117 | .create(200, "") 118 | .invoke(shouldFailedWith(new CDPAPIException(200, null, ""))); 119 | await(); 120 | } 121 | 122 | @Test 123 | public void should_fail_on_200_json_but_not_segment_schema_body() throws Exception { 124 | String body = "{\"some_random\": \"json\"}"; 125 | FetchUserSegmentsResult 126 | .create(200, body) 127 | // Because we do parse then re-format internally, 128 | // the JSON format of exception's message will not stay same as the 129 | // original response but be normalized. 130 | .invoke(shouldFailedWith(new CDPAPIException(200, null, new JSONObject(body).toString()))); 131 | await(); 132 | } 133 | 134 | @Test 135 | public void should_fail_and_parse_error_and_message_on_200_upon_json_error_schema() throws Exception { 136 | FetchUserSegmentsResult 137 | .create(200, "{" + 138 | " \"error\": \"Bad Request\"," + 139 | " \"message\": \"Some elaboration\"" + 140 | "}") 141 | .invoke(shouldFailedWith(new CDPAPIException(200, "Bad Request", "Some elaboration"))); 142 | await(); 143 | } 144 | 145 | private final FetchUserSegmentsCallback shouldSuccess = new FetchUserSegmentsCallback() { 146 | @Override 147 | public void onSuccess(List profiles) { 148 | latch.countDown(); 149 | } 150 | 151 | @Override 152 | public void onError(Exception e) { 153 | fail("Expect onError to be never get called!"); 154 | latch.countDown(); 155 | } 156 | }; 157 | 158 | private final FetchUserSegmentsCallback shouldFailed = new FetchUserSegmentsCallback() { 159 | @Override 160 | public void onSuccess(List profiles) { 161 | fail("Expect onSuccess to be get called!"); 162 | latch.countDown(); 163 | } 164 | 165 | @Override 166 | public void onError(Exception e) { 167 | latch.countDown(); 168 | } 169 | }; 170 | 171 | private FetchUserSegmentsCallback shouldFailedWith(final Exception expected) { 172 | return new FetchUserSegmentsCallback() { 173 | @Override 174 | public void onSuccess(List profiles) { 175 | fail("Expect onSuccess to be get called!"); 176 | latch.countDown(); 177 | } 178 | 179 | 180 | @Override 181 | public void onError(Exception exception) { 182 | // Pseudo equality checking, should probably do a better matcher here 183 | assertTrue(exception.getClass().isAssignableFrom(expected.getClass())); 184 | assertEquals(expected.getMessage(), exception.getMessage()); 185 | latch.countDown(); 186 | } 187 | }; 188 | } 189 | 190 | private FetchUserSegmentsCallback shouldFailedWith(final CDPAPIException expected) { 191 | return new FetchUserSegmentsCallback() { 192 | @Override 193 | public void onSuccess(List profiles) { 194 | fail("Expect onSuccess to be get called!"); 195 | latch.countDown(); 196 | } 197 | 198 | @Override 199 | public void onError(Exception exception) { 200 | assertTrue(exception instanceof CDPAPIException); 201 | CDPAPIException CDPAPIException = (CDPAPIException) exception; 202 | assertEquals(expected.getMessage(), CDPAPIException.getMessage()); 203 | assertEquals(expected.getError(), CDPAPIException.getError()); 204 | assertEquals(expected.getStatus(), CDPAPIException.getStatus()); 205 | latch.countDown(); 206 | } 207 | }; 208 | } 209 | 210 | private FetchUserSegmentsCallback shouldFailedWith(final String exceptionMessage) { 211 | return new FetchUserSegmentsCallback() { 212 | @Override 213 | public void onSuccess(List profiles) { 214 | fail("Expect onSuccess to be get called!"); 215 | latch.countDown(); 216 | } 217 | 218 | @Override 219 | public void onError(Exception e) { 220 | assertEquals(exceptionMessage, e.getMessage()); 221 | latch.countDown(); 222 | } 223 | }; 224 | } 225 | 226 | } -------------------------------------------------------------------------------- /src/test/java/com/treasuredata/android/cdp/ProfileImplTest.java: -------------------------------------------------------------------------------- 1 | package com.treasuredata.android.cdp; 2 | 3 | import org.json.JSONObject; 4 | import org.junit.Test; 5 | 6 | import static java.util.Arrays.asList; 7 | import static java.util.Collections.singletonList; 8 | import static java.util.Collections.singletonMap; 9 | import static org.junit.Assert.assertEquals; 10 | import static org.junit.Assert.assertNull; 11 | 12 | public class ProfileImplTest { 13 | 14 | @Test 15 | public void full_profile() throws Exception { 16 | ProfileImpl profile = ProfileImpl.fromJSONObject(new JSONObject( 17 | "{" + 18 | " \"values\": [\"123\"]," + 19 | " \"attributes\": {" + 20 | " \"a\": 1," + 21 | " \"b\": \"x\"," + 22 | " \"c\": [2, 3]," + 23 | " \"d\": {\"e\": 2}" + 24 | " }," + 25 | " \"audienceId\": \"234\"," + 26 | " \"key\": {\"a_key_column\": \"key_value\"}" + 27 | " }" 28 | )); 29 | 30 | assertEquals(singletonList("123"), profile.getSegments()); 31 | 32 | assertEquals(1, profile.getAttributes().get("a")); 33 | assertEquals("x", profile.getAttributes().get("b")); 34 | assertEquals(asList(2, 3), profile.getAttributes().get("c")); 35 | assertEquals(singletonMap("e", 2), profile.getAttributes().get("d")); 36 | 37 | assertEquals("234", profile.getAudienceId()); 38 | assertEquals("a_key_column", profile.getKey().getName()); 39 | assertEquals("key_value", profile.getKey().getValue()); 40 | } 41 | 42 | @Test 43 | public void profile_with_missing_props() throws Exception { 44 | ProfileImpl profile = ProfileImpl.fromJSONObject(new JSONObject( 45 | "{" + 46 | " \"values\": [\"123\"]," + 47 | " \"key\": {\"a_key_column\": \"key_value\"}" + 48 | " }" 49 | )); 50 | 51 | assertEquals(singletonList("123"), profile.getSegments()); 52 | assertNull(profile.getAttributes()); 53 | assertNull(profile.getAudienceId()); 54 | assertEquals("a_key_column", profile.getKey().getName()); 55 | assertEquals("key_value", profile.getKey().getValue()); 56 | } 57 | } -------------------------------------------------------------------------------- /test-host/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /test-host/README.md: -------------------------------------------------------------------------------- 1 | To be used a host runtime for unit tests that involve platform API. 2 | 3 | For the demo application, see `../example` 4 | -------------------------------------------------------------------------------- /test-host/build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | repositories { 3 | mavenCentral() 4 | google() 5 | } 6 | dependencies { 7 | classpath 'com.android.tools.build:gradle:8.4.0' 8 | } 9 | } 10 | 11 | apply plugin: 'com.android.application' 12 | 13 | android { 14 | defaultConfig { 15 | applicationId "com.treasuredata.android.test" 16 | compileSdk 34 17 | minSdkVersion 21 18 | targetSdkVersion 34 19 | versionCode 1 20 | versionName "1.0" 21 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 22 | } 23 | buildTypes { 24 | release { 25 | minifyEnabled false 26 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 27 | } 28 | } 29 | namespace 'com.treasuredata.android.test' 30 | } 31 | 32 | repositories { 33 | jcenter() 34 | google() 35 | mavenCentral() 36 | mavenLocal() 37 | } 38 | 39 | dependencies { 40 | implementation rootProject 41 | implementation fileTree(dir: 'libs', include: ['*.jar']) 42 | 43 | implementation 'androidx.appcompat:appcompat:1.2.0' 44 | 45 | implementation 'com.google.android.gms:play-services-ads:23.0.0' 46 | implementation 'com.treasuredata:keen-client-java-core:3.0.0' 47 | 48 | androidTestImplementation 'junit:junit:4.12' 49 | 50 | androidTestImplementation 'org.mockito:mockito-android:5.12.0' 51 | androidTestImplementation "androidx.test:runner:1.5.2" 52 | androidTestImplementation "androidx.test.espresso:espresso-core:3.5.1" 53 | } 54 | -------------------------------------------------------------------------------- /test-host/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | -------------------------------------------------------------------------------- /test-host/src/androidTest/java/com/treasuredata/android/GetAdvertisingAsyncTaskTest.java: -------------------------------------------------------------------------------- 1 | package com.treasuredata.android; 2 | 3 | import android.content.Context; 4 | import androidx.test.InstrumentationRegistry; 5 | import androidx.test.runner.AndroidJUnit4; 6 | 7 | import junit.framework.TestCase; 8 | 9 | import org.junit.Test; 10 | import org.junit.runner.RunWith; 11 | 12 | import java.util.concurrent.CountDownLatch; 13 | 14 | @RunWith(AndroidJUnit4.class) 15 | public class GetAdvertisingAsyncTaskTest extends TestCase { 16 | @Test 17 | public void getAdvertisingId() { 18 | final CountDownLatch latch = new CountDownLatch(1); 19 | Context context = InstrumentationRegistry.getTargetContext(); 20 | final GetAdvertisingIdAsyncTask task = 21 | new GetAdvertisingIdAsyncTask(new GetAdvertisingIdAsyncTaskCallback() { 22 | @Override 23 | public void onGetAdvertisingIdAsyncTaskCompleted(String advertisingId) { 24 | latch.countDown(); 25 | assertNotNull(advertisingId); 26 | } 27 | }); 28 | task.execute(context); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /test-host/src/androidTest/java/com/treasuredata/android/TreasureDataInstrumentTest.java: -------------------------------------------------------------------------------- 1 | package com.treasuredata.android; 2 | 3 | import android.app.Application; 4 | import android.content.Context; 5 | import androidx.test.InstrumentationRegistry; 6 | import androidx.test.runner.AndroidJUnit4; 7 | 8 | import junit.framework.TestCase; 9 | 10 | import java.io.IOException; 11 | import java.util.ArrayList; 12 | import java.util.HashMap; 13 | import java.util.List; 14 | import java.util.Map; 15 | 16 | import io.keen.client.java.KeenCallback; 17 | import io.keen.client.java.KeenProject; 18 | 19 | import static org.mockito.Mockito.mock; 20 | import static org.mockito.Mockito.spy; 21 | 22 | import org.junit.Before; 23 | import org.junit.Ignore; 24 | import org.junit.Test; 25 | import org.junit.runner.RunWith; 26 | 27 | @RunWith(AndroidJUnit4.class) 28 | public class TreasureDataInstrumentTest extends TestCase { 29 | private static final String DUMMY_API_KEY = "dummy_api_key"; 30 | 31 | class Event { 32 | String tag; 33 | Map event; 34 | Event(String tag, Mapevent) { 35 | this.tag = tag; 36 | this.event = event; 37 | } 38 | } 39 | 40 | class MockTDClient extends TDClient { 41 | Exception exceptionOnQueueEventCalled; 42 | Exception exceptionOnSendQueuedEventsCalled; 43 | String errorCodeOnQueueEventCalled; 44 | String errorCodeOnSendQueuedEventsCalled; 45 | List addedEvent = new ArrayList(); 46 | 47 | MockTDClient(String apiKey) throws IOException { 48 | super(apiKey); 49 | } 50 | 51 | public void clearAddedEvent() { 52 | addedEvent = new ArrayList(); 53 | } 54 | 55 | @Override 56 | public void queueEvent(KeenProject project, String eventCollection, Map event, Map keenProperties, KeenCallback callback) { 57 | if (exceptionOnQueueEventCalled == null) { 58 | addedEvent.add(new Event(eventCollection, event)); 59 | callback.onSuccess(); 60 | } 61 | else { 62 | if (callback instanceof KeenCallbackWithErrorCode) { 63 | KeenCallbackWithErrorCode callbackWithErrorCode = (KeenCallbackWithErrorCode) callback; 64 | callbackWithErrorCode.setErrorCode(errorCodeOnQueueEventCalled); 65 | callbackWithErrorCode.onFailure(exceptionOnQueueEventCalled); 66 | } 67 | else { 68 | callback.onFailure(exceptionOnQueueEventCalled); 69 | } 70 | } 71 | } 72 | 73 | @Override 74 | public void sendQueuedEventsAsync(KeenProject project, KeenCallback callback) { 75 | if (exceptionOnSendQueuedEventsCalled == null) { 76 | callback.onSuccess(); 77 | } 78 | else { 79 | if (callback instanceof KeenCallbackWithErrorCode) { 80 | ((KeenCallbackWithErrorCode) callback).setErrorCode(errorCodeOnSendQueuedEventsCalled); 81 | } 82 | callback.onFailure(exceptionOnSendQueuedEventsCalled); 83 | } 84 | } 85 | } 86 | 87 | private Context context; 88 | private MockTDClient client; 89 | private TreasureData td; 90 | 91 | private TreasureData createTreasureData(Context context, TDClient client) { 92 | return new TreasureData(context, client); 93 | } 94 | 95 | @Before 96 | public void setUp() throws IOException { 97 | Application application = mock(Application.class); 98 | context = InstrumentationRegistry.getTargetContext(); 99 | 100 | client = new MockTDClient(DUMMY_API_KEY); 101 | td = spy(createTreasureData(context, client)); 102 | } 103 | 104 | @Test 105 | public void testEnableAutoAppendAdvertisingId() throws IOException { 106 | td.enableAutoAppendAdvertisingIdentifier(); 107 | try { 108 | Thread.sleep(1000); 109 | } catch (InterruptedException e) { 110 | fail(); 111 | } 112 | 113 | Map records = new HashMap(); 114 | records.put("key", "val"); 115 | td.addEvent("db_", "tbl", records); 116 | td.uploadEvents(); 117 | assertEquals(1, client.addedEvent.size()); 118 | assertEquals("db_.tbl", client.addedEvent.get(0).tag); 119 | assertEquals(2, client.addedEvent.get(0).event.size()); 120 | assertTrue(client.addedEvent.get(0).event.containsKey("key")); 121 | assertTrue(client.addedEvent.get(0).event.containsValue("val")); 122 | assertTrue(client.addedEvent.get(0).event.containsKey("td_maid")); 123 | } 124 | 125 | @Test 126 | public void testDisableAutoAppendAdvertisingId() throws IOException { 127 | td.enableAutoAppendAdvertisingIdentifier(); 128 | td.disableAutoAppendAdvertisingIdentifier(); 129 | try { 130 | Thread.sleep(1000); 131 | } catch (InterruptedException e) { 132 | fail(); 133 | } 134 | 135 | Maprecords = new HashMap(); 136 | records.put("key", "val"); 137 | td.addEvent("db_", "tbl", records); 138 | td.uploadEvents(); 139 | assertEquals(1, client.addedEvent.size()); 140 | assertEquals("db_.tbl", client.addedEvent.get(0).tag); 141 | assertEquals(1, client.addedEvent.get(0).event.size()); 142 | assertTrue(client.addedEvent.get(0).event.containsKey("key")); 143 | assertTrue(client.addedEvent.get(0).event.containsValue("val")); 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /test-host/src/androidTest/java/com/treasuredata/android/cdp/CDPClientImplTest.java: -------------------------------------------------------------------------------- 1 | package com.treasuredata.android.cdp; 2 | 3 | import android.os.Handler; 4 | import android.os.Looper; 5 | import androidx.test.runner.AndroidJUnit4; 6 | import org.junit.Test; 7 | import org.junit.runner.RunWith; 8 | import org.mockito.Mockito; 9 | 10 | import java.util.Collections; 11 | import java.util.List; 12 | import java.util.Map; 13 | import java.util.concurrent.CountDownLatch; 14 | import java.util.concurrent.TimeUnit; 15 | 16 | import static android.os.Looper.getMainLooper; 17 | import static android.os.Looper.myLooper; 18 | import static org.junit.Assert.assertEquals; 19 | import static org.junit.Assert.assertNull; 20 | import static org.junit.Assert.fail; 21 | import static org.mockito.Mockito.doReturn; 22 | 23 | @RunWith(AndroidJUnit4.class) 24 | public class CDPClientImplTest { 25 | 26 | @Test 27 | public void call_fetchUserSegments_from_a_thread_WITHOUT_a_looper_associated_should_be_called_back_into_main_loop() throws Exception { 28 | assertNull("VERIFICATION: This thread should not be associated with any looper.", myLooper()); 29 | final CountDownLatch latch = new CountDownLatch(2); 30 | 31 | final CDPClientImpl cdpClient = Mockito.spy(new CDPClientImpl("https://cdp.in.treasuredata.com")); 32 | doReturn(FetchUserSegmentsResult.create(200, "[]")) 33 | .when(cdpClient) 34 | .fetchUserSegmentResultSynchronously(Mockito.>any(), Mockito.>any()); 35 | 36 | // With onSuccess 37 | cdpClient.fetchUserSegments( 38 | Collections.emptyList(), 39 | Collections.emptyMap(), 40 | new FetchUserSegmentsCallback() { 41 | @Override 42 | public void onSuccess(List profiles) { 43 | latch.countDown(); 44 | assertEquals("Expected should be dispatch to main looper", getMainLooper(), myLooper()); 45 | } 46 | @Override 47 | public void onError(Exception e) { 48 | latch.countDown(); 49 | fail(); 50 | } 51 | }); 52 | 53 | final CDPClientImpl failureCdpClient = Mockito.spy(new CDPClientImpl("https://cdp.in.treasuredata.com")); 54 | doReturn(FetchUserSegmentsResult.create(400, "an_error")) 55 | .when(failureCdpClient) 56 | .fetchUserSegmentResultSynchronously(Mockito.>any(), Mockito.>any()); 57 | 58 | // Same for onError 59 | failureCdpClient.fetchUserSegments( 60 | Collections.emptyList(), 61 | Collections.emptyMap(), 62 | new FetchUserSegmentsCallback() { 63 | @Override 64 | public void onSuccess(List profiles) { 65 | latch.countDown(); 66 | fail(); 67 | } 68 | @Override 69 | public void onError(Exception e) { 70 | latch.countDown(); 71 | assertEquals("Expected should be dispatch to main looper", getMainLooper(), myLooper()); 72 | } 73 | }); 74 | 75 | latch.await(1, TimeUnit.SECONDS); 76 | } 77 | 78 | @Test 79 | public void call_fetchUserSegments_from_a_thread_WITH_a_looper_associated_should_be_called_back_in_that_looper() throws Exception { 80 | final CountDownLatch latch = new CountDownLatch(2); 81 | 82 | final CDPClientImpl cdpClient = Mockito.spy(new CDPClientImpl("https://cdp.in.treasuredata.com")); 83 | doReturn(FetchUserSegmentsResult.create(200, "[]")) 84 | .when(cdpClient) 85 | .fetchUserSegmentResultSynchronously(Mockito.>any(), Mockito.>any()); 86 | 87 | // With onSuccess 88 | new Thread(new Runnable() { 89 | @Override 90 | public void run() { 91 | Looper.prepare(); 92 | final Looper callerLooper = myLooper(); 93 | cdpClient.fetchUserSegments( 94 | Collections.emptyList(), 95 | Collections.emptyMap(), 96 | new FetchUserSegmentsCallback() { 97 | @Override 98 | public void onSuccess(List profiles) { 99 | latch.countDown(); 100 | assertEquals("Expected this should be dispatch to the caller looper", myLooper(), callerLooper); 101 | if (callerLooper != null) callerLooper.quit(); 102 | } 103 | @Override 104 | public void onError(Exception e) { 105 | latch.countDown(); 106 | fail(); 107 | if (callerLooper != null) callerLooper.quit(); 108 | } 109 | }); 110 | Looper.loop(); 111 | } 112 | }).start(); 113 | 114 | final CDPClientImpl failureCdpClient = Mockito.spy(new CDPClientImpl("https://cdp.in.treasuredata.com")); 115 | doReturn(FetchUserSegmentsResult.create(400, "an_error")) 116 | .when(failureCdpClient) 117 | .fetchUserSegmentResultSynchronously(Mockito.>any(), Mockito.>any()); 118 | 119 | // Same for onError 120 | new Thread(new Runnable() { 121 | @Override 122 | public void run() { 123 | Looper.prepare(); 124 | final Looper callerLooper = myLooper(); 125 | failureCdpClient.fetchUserSegments( 126 | Collections.emptyList(), 127 | Collections.emptyMap(), 128 | new FetchUserSegmentsCallback() { 129 | @Override 130 | public void onSuccess(List profiles) { 131 | latch.countDown(); 132 | if (callerLooper != null) callerLooper.quit(); 133 | fail(); 134 | } 135 | @Override 136 | public void onError(Exception e) { 137 | latch.countDown(); 138 | assertEquals("Expected this should be dispatch to the caller looper", myLooper(), callerLooper); 139 | if (callerLooper != null) callerLooper.quit(); 140 | } 141 | }); 142 | Looper.loop(); 143 | } 144 | }).start(); 145 | 146 | latch.await(1, TimeUnit.SECONDS); 147 | } 148 | 149 | @Test 150 | public void call_fetchUserSegments_from_the_main_loop_apparently_should_be_called_back_in_the_main_loop() throws Exception { 151 | final CountDownLatch latch = new CountDownLatch(1); 152 | 153 | final CDPClientImpl cdpClient = Mockito.spy(new CDPClientImpl("https://cdp.in.treasuredata.com")); 154 | doReturn(FetchUserSegmentsResult.create(200, "[]")) 155 | .when(cdpClient) 156 | .fetchUserSegmentResultSynchronously(Mockito.>any(), Mockito.>any()); 157 | 158 | // With onSuccess 159 | new Handler(getMainLooper()).post(new Runnable() { 160 | @Override 161 | public void run() { 162 | cdpClient.fetchUserSegments( 163 | Collections.emptyList(), 164 | Collections.emptyMap(), 165 | new FetchUserSegmentsCallback() { 166 | @Override 167 | public void onSuccess(List profiles) { 168 | latch.countDown(); 169 | assertEquals("Expected this should be dispatch to the main loop", getMainLooper(), myLooper()); 170 | } 171 | @Override 172 | public void onError(Exception e) { 173 | latch.countDown(); 174 | fail(); 175 | } 176 | }); 177 | } 178 | }); 179 | 180 | final CDPClientImpl failureCdpClient = Mockito.spy(new CDPClientImpl("https://cdp.in.treasuredata.com")); 181 | doReturn(FetchUserSegmentsResult.create(400, "an_error")) 182 | .when(failureCdpClient) 183 | .fetchUserSegmentResultSynchronously(Mockito.>any(), Mockito.>any()); 184 | 185 | new Handler(getMainLooper()).post(new Runnable() { 186 | @Override 187 | public void run() { 188 | failureCdpClient.fetchUserSegments( 189 | Collections.emptyList(), 190 | Collections.emptyMap(), 191 | new FetchUserSegmentsCallback() { 192 | @Override 193 | public void onSuccess(List profiles) { 194 | latch.countDown(); 195 | fail(); 196 | } 197 | @Override 198 | public void onError(Exception e) { 199 | latch.countDown(); 200 | assertEquals("Expected this should be dispatch to the main loop", getMainLooper(), myLooper()); 201 | } 202 | }); 203 | } 204 | }); 205 | 206 | latch.await(1, TimeUnit.SECONDS); 207 | } 208 | 209 | } 210 | -------------------------------------------------------------------------------- /test-host/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 11 | 14 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /test-host/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 12 | 13 | 19 | 22 | 25 | 26 | 27 | 28 | 34 | 35 | -------------------------------------------------------------------------------- /test-host/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 10 | 12 | 14 | 16 | 18 | 20 | 22 | 24 | 26 | 28 | 30 | 32 | 34 | 36 | 38 | 40 | 42 | 44 | 46 | 48 | 50 | 52 | 54 | 56 | 58 | 60 | 62 | 64 | 66 | 68 | 70 | 72 | 74 | 75 | -------------------------------------------------------------------------------- /test-host/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /test-host/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /test-host/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/treasure-data/td-android-sdk/df71c6a3aab9c0cf3ebcac858340c1c6b013e461/test-host/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /test-host/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/treasure-data/td-android-sdk/df71c6a3aab9c0cf3ebcac858340c1c6b013e461/test-host/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /test-host/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/treasure-data/td-android-sdk/df71c6a3aab9c0cf3ebcac858340c1c6b013e461/test-host/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /test-host/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/treasure-data/td-android-sdk/df71c6a3aab9c0cf3ebcac858340c1c6b013e461/test-host/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /test-host/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/treasure-data/td-android-sdk/df71c6a3aab9c0cf3ebcac858340c1c6b013e461/test-host/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /test-host/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/treasure-data/td-android-sdk/df71c6a3aab9c0cf3ebcac858340c1c6b013e461/test-host/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /test-host/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/treasure-data/td-android-sdk/df71c6a3aab9c0cf3ebcac858340c1c6b013e461/test-host/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /test-host/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/treasure-data/td-android-sdk/df71c6a3aab9c0cf3ebcac858340c1c6b013e461/test-host/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /test-host/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/treasure-data/td-android-sdk/df71c6a3aab9c0cf3ebcac858340c1c6b013e461/test-host/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /test-host/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/treasure-data/td-android-sdk/df71c6a3aab9c0cf3ebcac858340c1c6b013e461/test-host/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /test-host/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #008577 4 | #00574B 5 | #D81B60 6 | 7 | -------------------------------------------------------------------------------- /test-host/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | TestHost 3 | 4 | -------------------------------------------------------------------------------- /test-host/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 11 | 12 | --------------------------------------------------------------------------------