├── .editorconfig ├── .gitmodules ├── .woodpecker └── main.yml ├── LICENSE ├── README.md ├── app ├── .gitignore ├── build.gradle ├── proguard-rules.pro └── src │ ├── fcm │ ├── AndroidManifest.xml │ ├── google-services.json │ └── java │ │ └── org │ │ └── unifiedpush │ │ └── example │ │ └── EmbeddedDistributor.kt │ └── main │ ├── AndroidManifest.xml │ ├── ic_launcher-playstore.png │ ├── java │ └── org │ │ └── unifiedpush │ │ └── example │ │ ├── ApplicationServer.kt │ │ ├── PushServiceImpl.kt │ │ ├── Store.kt │ │ ├── TestService.kt │ │ ├── Tests.kt │ │ ├── Urgency.kt │ │ ├── activities │ │ ├── AppBarViewModel.kt │ │ ├── CheckActivity.kt │ │ ├── CheckViewModel.kt │ │ ├── Events.kt │ │ ├── MainActivity.kt │ │ └── ui │ │ │ ├── AppBarUi.kt │ │ │ ├── AppBarUiState.kt │ │ │ ├── CheckUi.kt │ │ │ ├── CheckUiState.kt │ │ │ ├── MainUi.kt │ │ │ ├── PermissionsUi.kt │ │ │ ├── SetUrgencyDialog.kt │ │ │ └── theme │ │ │ ├── Color.kt │ │ │ ├── Theme.kt │ │ │ └── Type.kt │ │ └── utils │ │ ├── DelayedRequestWorker.kt │ │ ├── MessageParser.kt │ │ ├── Notifier.kt │ │ ├── RawRequest.kt │ │ ├── RegistrationDialogs.kt │ │ ├── Tag.kt │ │ └── Utils.kt │ └── res │ ├── drawable │ ├── ic_launcher_foreground.xml │ └── ic_launcher_notification.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 │ ├── ic_launcher_background.xml │ ├── strings.xml │ └── themes.xml │ └── xml │ └── network_security_config.xml ├── build.gradle ├── connector ├── connector_ui ├── distributor ├── fastlane └── metadata │ └── android │ └── en-US │ ├── changelogs │ ├── 10.txt │ ├── 11.txt │ ├── 12.txt │ ├── 13.txt │ ├── 14.txt │ ├── 15.txt │ ├── 16.txt │ ├── 17.txt │ ├── 18.txt │ ├── 19.txt │ ├── 20.txt │ ├── 21.txt │ ├── 22.txt │ ├── 23.txt │ ├── 24.txt │ ├── 25.txt │ ├── 26.txt │ └── 27.txt │ ├── full_description.txt │ ├── images │ ├── icon.png │ └── phoneScreenshots │ │ ├── 1.png │ │ ├── 2.png │ │ └── 3.png │ ├── short_description.txt │ └── title.txt ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat └── settings.gradle /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{kt,kts}] 2 | ktlint_code_style = android_studio 3 | ktlint_function_naming_ignore_when_annotated_with=Composable 4 | max_line_length = 140 5 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/UnifiedPush/android-example/f6923ce14445ac78866f0cb376211f4540481b88/.gitmodules -------------------------------------------------------------------------------- /.woodpecker/main.yml: -------------------------------------------------------------------------------- 1 | steps: 2 | check: 3 | # https://github.com/MobileDevOps/android-sdk-image 4 | image: mobiledevops/android-sdk-image:latest 5 | when: 6 | branch: main 7 | event: [push, pull_request, manual] 8 | commands: 9 | - ./gradlew build --stacktrace 10 | - mv app/build/outputs/apk/fcm/debug/app-fcm-debug.apk UP-Example-fcm.apk 11 | - mv app/build/outputs/apk/mainFlavor/debug/app-mainFlavor-debug.apk UP-Example-main.apk 12 | 13 | build: 14 | # https://github.com/MobileDevOps/android-sdk-image 15 | image: mobiledevops/android-sdk-image:latest 16 | when: 17 | branch: main 18 | event: tag 19 | commands: 20 | - export RELEASE_STORE_FILE=$PWD/release-key.jks 21 | - echo $RELEASE_KEY | base64 -d > $RELEASE_STORE_FILE 22 | - ./gradlew -Psign assembleRelease --stacktrace 23 | - mv app/build/outputs/apk/fcm/release/app-fcm-release.apk UP-Example-fcm.apk 24 | - mv app/build/outputs/apk/mainFlavor/release/app-mainFlavor-release.apk UP-Example-main.apk 25 | environment: 26 | RELEASE_KEY_ALIAS: unifiedpush 27 | RELEASE_KEY: 28 | from_secret: release_key 29 | RELEASE_STORE_PASSWORD: 30 | from_secret: release_store_password 31 | RELEASE_KEY_PASSWORD: 32 | from_secret: release_key_password 33 | 34 | upload: 35 | image: codeberg.org/s1m/woodpecker-upload:latest 36 | when: 37 | branch: main 38 | event: [push, pull_request, tag, manual] 39 | settings: 40 | token: 41 | from_secret: codeberg_token 42 | file: 43 | - UP-Example-main.apk 44 | - UP-Example-fcm.apk 45 | fastlane: true 46 | package: true 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2021 Simon Gougeon 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # UnifiedPush Example 2 | 3 | This application is a generic application to handle notifications using UnifiedPush which can be used to test your setup. It is an example how to use [the UnifiedPush library](https://codeberg.org/UnifiedPush/android-connector). 4 | 5 | [Get it on F-Droid](https://f-droid.org/packages/org.unifiedpush.example/) 8 | 9 | ## Receive notifications from a terminal 10 | 11 | You can use this app as a rustic application to receive notifications send from a terminal/a process 12 | 13 | #### Via encrypted WebPush requests 14 | 15 | Toggle ON "WebPush" before registering on UP-Example, and send WebPush requests. 16 | 17 | With a python script for instance: 18 | 19 | ```py 20 | #!/usr/bin/env python 21 | 22 | from pywebpush import webpush 23 | import urllib 24 | import sys 25 | 26 | if len(sys.argv) < 2: 27 | print("Usage: {} message".format(sys.argv[0])) 28 | 29 | subinfo = { 30 | "endpoint": "YOUR_ENDPOINT_HERE", 31 | "keys": { 32 | "auth": "AUTH_SECRET_HERE", 33 | "p256dh": "P256DH_SECRET_HERE" 34 | } 35 | } 36 | 37 | message = "title=UP!&message=" + urllib.parse.quote(' '.join(sys.argv[1::])) 38 | 39 | webpush(subinfo, message) 40 | ``` 41 | 42 | To use it: `./notify.py My message here` 43 | 44 | Depending on your distributor, you may need to set the VAPID header too: add `headers={"authorization": "vapid t=[...],k=[...]"}` to the webpush call. 45 | 46 | #### Via unencrypted requests 47 | 48 | Push notifications are intended to be encrypted. But you can send unencrypted requests by sending HTTP POST requests with the header `Content-Encoding: aes128gcm`. 49 | Depending on your distributor, you may need to set the VAPID header too: add `Authorization: vapid t=[...],k=[...]`. 50 | 51 | For instance with cURL: 52 | 53 | ``` 54 | curl -X POST $endpoint -H "Content-encoding: aes128gcm" --data "title={Your Title}&message={Your Message}" 55 | ``` 56 | 57 | ## Developer mode, to test a distributor 58 | 59 | This application can be used to test different features of a distributor. To enable this mode, check `Developer mode` in the upper right menu. 60 | 61 | You will be able to: 62 | - Show an error notification if the received message hasn't been correctly decrypted, by checking `Error if decryption fails`. 63 | - Use VAPID: 64 | - After toggling this setting or after renewing VAPID key, you must "reregister" your application. This is not done automatically to allow testing different cases. 65 | - The VAPID header is cached for 5 minutes: following RFC8292, push servers should cache the JWT to avoid checking the signature every time. This allows to test it. 66 | - Send cleartext messages 67 | - Use wrong VAPID keys, you should not receive new messages. 68 | - Use wrong encryption keys, decryption for new messages will fail. 69 | - Start a foreground service when a message is received, by checking `Foreground service on message`. It must work even if the example application has optimized battery, and is in the background. 70 | - Resend registration message, by clicking on `Reregister`. 71 | - Start the link activity using deep link by clicking on `Deep link`. 72 | - Change the distributor, without using the deep link by clicking on `Change distributor`. 73 | - Set urgency for new messages. 74 | - Test TTL. The TTL is correctly implemented if you don't receive the test message. You will have to disconnect the distributor during the process. 75 | - Test topics. The topics are correctly implemented if you don't receive the 1st test message which would have been replaced by the 2nd. You will have to disconnect the distributor during the process. 76 | - Test push while the application is in the background 77 | 78 | ## Development 79 | 80 | #### CI Secrets 81 | * `release_key`: keystore in base64 82 | * `release_store_password`: keystore password 83 | * `release_key_password`: key password, the key alias must be `unifiedpush` 84 | * `codeberg_token`: codeberg token for package, with `write:package` right (https://codeberg.org/user/settings/applications) 85 | 86 | # Funding 87 | 88 | This project is funded through [NGI Zero Core](https://nlnet.nl/core), a fund established by [NLnet](https://nlnet.nl) with financial support from the European Commission's [Next Generation Internet](https://ngi.eu) program. Learn more at the [NLnet project page](https://nlnet.nl/project/UnifiedPush). 89 | 90 | [NLnet foundation logo](https://nlnet.nl) 91 | [NGI Zero Logo](https://nlnet.nl/core) 92 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'com.android.application' 3 | id 'kotlin-android' 4 | id 'org.jetbrains.kotlin.plugin.compose' 5 | } 6 | 7 | android { 8 | compileSdk 35 9 | 10 | compileOptions { 11 | sourceCompatibility = 17 12 | targetCompatibility = 17 13 | } 14 | 15 | buildFeatures { 16 | compose true 17 | } 18 | 19 | defaultConfig { 20 | applicationId "org.unifiedpush.example" 21 | targetSdk 35 22 | minSdk 21 23 | versionCode 27 24 | versionName "2.0.3" 25 | multiDexEnabled true 26 | 27 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 28 | } 29 | 30 | buildTypes { 31 | release { 32 | minifyEnabled true 33 | shrinkResources true 34 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 35 | } 36 | } 37 | 38 | flavorDimensions = ["version"] 39 | productFlavors { 40 | mainFlavor { 41 | dimension "version" 42 | } 43 | 44 | fcm { 45 | dimension "version" 46 | versionNameSuffix "-fcm" 47 | } 48 | } 49 | 50 | namespace 'org.unifiedpush.example' 51 | } 52 | 53 | if (project.hasProperty('sign')) { 54 | android { 55 | signingConfigs { 56 | release { 57 | storeFile file(System.getenv("RELEASE_STORE_FILE")) 58 | storePassword System.getenv("RELEASE_STORE_PASSWORD") 59 | keyAlias System.getenv("RELEASE_KEY_ALIAS") 60 | keyPassword System.getenv("RELEASE_KEY_PASSWORD") 61 | } 62 | } 63 | } 64 | android.buildTypes.release.signingConfig android.signingConfigs.release 65 | } 66 | 67 | dependencies { 68 | ext.connector = 'org.unifiedpush.android:connector:3.0.4' 69 | ext.connector_ui = "org.unifiedpush.android:connector-ui:1.1.0" 70 | ext.embedded_distrib = 'org.unifiedpush.android:embedded-fcm-distributor:3.0.0-rc1' 71 | 72 | ext.uiTooling = "1.7.5" 73 | implementation "androidx.compose.material3:material3-android:1.3.1" 74 | implementation "androidx.compose.ui:ui-tooling-preview-android:$uiTooling" 75 | implementation "androidx.compose.ui:ui-tooling:$uiTooling" 76 | implementation "androidx.activity:activity-compose:1.9.3" 77 | implementation "com.google.accompanist:accompanist-permissions:0.36.0" 78 | 79 | implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" 80 | mainFlavorImplementation 'androidx.appcompat:appcompat:1.7.0' 81 | fcmImplementation 'androidx.appcompat:appcompat:1.7.0' 82 | mainFlavorImplementation 'com.google.android.material:material:1.12.0' 83 | fcmImplementation 'com.google.android.material:material:1.12.0' 84 | implementation 'com.google.crypto.tink:apps-webpush:1.11.0' 85 | implementation 'com.android.volley:volley:1.2.1' 86 | implementation 'androidx.constraintlayout:constraintlayout:2.2.0' 87 | implementation "androidx.multidex:multidex:2.0.1" 88 | implementation "androidx.work:work-runtime:2.10.0" 89 | 90 | //Flavors 91 | implementation connector //delToDevMain// 92 | //toDevMain// implementation project(':connector') 93 | implementation connector_ui //delToDevUILib// 94 | //toDevUILib// implementation project(':connector_ui') 95 | 96 | fcmImplementation(embedded_distrib) //delToDevFcm// 97 | //toDevFcm// fcmImplementation project(':distributor') 98 | } 99 | -------------------------------------------------------------------------------- /app/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 | -dontwarn com.google.firebase.analytics.connector.AnalyticsConnector -------------------------------------------------------------------------------- /app/src/fcm/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /app/src/fcm/google-services.json: -------------------------------------------------------------------------------- 1 | 2 | { 3 | "project_info": { 4 | "project_number": "64518491375", 5 | "project_id": "unifiedpush-3f07b", 6 | "storage_bucket": "unifiedpush-3f07b.appspot.com" 7 | }, 8 | "client": [ 9 | { 10 | "client_info": { 11 | "mobilesdk_app_id": "1:64518491375:android:839eaf67570270e2b69f1a", 12 | "android_client_info": { 13 | "package_name": "org.unifiedpush.example" 14 | } 15 | }, 16 | "oauth_client": [ 17 | { 18 | "client_id": "64518491375-f8ds3h419jcbesmform13mdl2kcg0ois.apps.googleusercontent.com", 19 | "client_type": 3 20 | } 21 | ], 22 | "api_key": [ 23 | { 24 | "current_key": "AIzaSyBVSqffMY71m_Jp1chpq4FpZdQ1UTtU4a0" 25 | } 26 | ], 27 | "services": { 28 | "appinvite_service": { 29 | "other_platform_oauth_client": [ 30 | { 31 | "client_id": "64518491375-f8ds3h419jcbesmform13mdl2kcg0ois.apps.googleusercontent.com", 32 | "client_type": 3 33 | } 34 | ] 35 | } 36 | } 37 | } 38 | ], 39 | "configuration_version": "1" 40 | } 41 | -------------------------------------------------------------------------------- /app/src/fcm/java/org/unifiedpush/example/EmbeddedDistributor.kt: -------------------------------------------------------------------------------- 1 | package org.unifiedpush.example 2 | 3 | import org.unifiedpush.android.embedded_fcm_distributor.DefaultGateway 4 | import org.unifiedpush.android.embedded_fcm_distributor.EmbeddedDistributorReceiver 5 | 6 | class EmbeddedDistributor : EmbeddedDistributorReceiver() { 7 | override val gateway = DefaultGateway 8 | } 9 | -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 19 | 22 | 23 | 24 | 25 | 26 | 27 | 30 | 32 | 33 | 34 | 35 | 36 | 40 | 42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /app/src/main/ic_launcher-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/UnifiedPush/android-example/f6923ce14445ac78866f0cb376211f4540481b88/app/src/main/ic_launcher-playstore.png -------------------------------------------------------------------------------- /app/src/main/java/org/unifiedpush/example/ApplicationServer.kt: -------------------------------------------------------------------------------- 1 | package org.unifiedpush.example 2 | 3 | import android.content.Context 4 | import android.os.Build 5 | import android.security.keystore.KeyGenParameterSpec 6 | import android.security.keystore.KeyProperties 7 | import android.util.Log 8 | import android.widget.Toast 9 | import androidx.annotation.RequiresApi 10 | import com.android.volley.NetworkResponse 11 | import com.android.volley.RequestQueue 12 | import com.android.volley.Response 13 | import com.android.volley.VolleyError 14 | import com.android.volley.toolbox.StringRequest 15 | import com.android.volley.toolbox.Volley 16 | import com.google.crypto.tink.apps.webpush.WebPushHybridEncrypt 17 | import com.google.crypto.tink.subtle.EllipticCurves 18 | import java.net.URL 19 | import java.security.KeyPair 20 | import java.security.KeyPairGenerator 21 | import java.security.KeyStore 22 | import java.security.KeyStore.PrivateKeyEntry 23 | import java.security.SecureRandom 24 | import java.security.Signature 25 | import java.security.interfaces.ECPublicKey 26 | import java.security.spec.ECGenParameterSpec 27 | import java.util.Calendar 28 | import org.json.JSONObject 29 | import org.unifiedpush.example.utils.RawRequest 30 | import org.unifiedpush.example.utils.TAG 31 | import org.unifiedpush.example.utils.b64decode 32 | import org.unifiedpush.example.utils.b64encode 33 | import org.unifiedpush.example.utils.decodePubKey 34 | import org.unifiedpush.example.utils.encode 35 | import org.unifiedpush.example.utils.vapidImplementedForSdk 36 | 37 | /** 38 | * This class emulates an application server 39 | */ 40 | class ApplicationServer(private val context: Context) { 41 | private val store = Store(context) 42 | 43 | /** 44 | * Emulate notification sent from the application server to the push service. 45 | * 46 | * If the request fail, [callback] runs with the error message. 47 | */ 48 | fun sendNotification(callback: (error: String?) -> Unit) { 49 | if (store.devMode && store.devCleartextTest) { 50 | sendPlainTextNotification { e -> 51 | callbackWithToasts(e, callback) 52 | } 53 | } else if (store.devMode && store.devWrongKeysTest) { 54 | sendWebPushNotification(content = "This is impossible to decrypt", fakeKeys = true) { _, e -> 55 | callbackWithToasts(e, callback) 56 | } 57 | } else { 58 | sendWebPushNotification(content = "WebPush test", fakeKeys = false) { _, e -> 59 | callbackWithToasts(e, callback) 60 | } 61 | } 62 | } 63 | 64 | private fun callbackWithToasts(e: VolleyError?, callback: (error: String?) -> Unit) { 65 | e?.let { 66 | Toast.makeText(context, "An error occurred.", Toast.LENGTH_SHORT).show() 67 | callback(it.toString()) 68 | } ?: run { 69 | Toast.makeText(context, "Done", Toast.LENGTH_SHORT).show() 70 | callback(null) 71 | } 72 | } 73 | 74 | /** 75 | * Emulate notification sent from the application server to the push service with a TTL. 76 | * 77 | * If the request fail, or the response doesn't contain TTL=5 [callback] runs with the error message. 78 | */ 79 | fun sendTestTTLNotification(callback: (error: String?) -> Unit) { 80 | sendWebPushNotification(content = "This must be deleted before being delivered.", fakeKeys = false) { r, e -> 81 | e?.let { return@sendWebPushNotification callback(e.toString()) } 82 | r?.let { rep -> 83 | var ttl: String? 84 | if (rep.headers?.keys?.contains("TTL") != true) { 85 | return@sendWebPushNotification callback("The response doesn't contain TTL header.") 86 | } else if (rep.headers?.get("TTL").also { ttl = it } != "5") { 87 | return@sendWebPushNotification callback("The response doesn't support TTL for 5 seconds (TTL=$ttl).") 88 | } else { 89 | return@sendWebPushNotification callback(null) 90 | } 91 | } 92 | } 93 | } 94 | 95 | /** 96 | * Emulate 2 notifications sent from the application server to the push service with the same topic. 97 | * 98 | * If the distributor is not connected, the first push message should be override. 99 | * 100 | * If the request fail [callback] runs with the error message. 101 | */ 102 | fun sendTestTopicNotifications(callback: (error: String?) -> Unit) { 103 | sendWebPushNotification( 104 | content = "1st notification, it must be replaced before being delivered.", 105 | fakeKeys = false, 106 | topic = "test", 107 | ttl = 60 108 | ) { _, e1 -> 109 | e1?.let { return@sendWebPushNotification callbackWithToasts(e1, callback) } 110 | ?: run { 111 | sendWebPushNotification( 112 | content = "2nd notification, it must have replaced the previous one.", 113 | fakeKeys = false, 114 | topic = "test", 115 | ttl = 60 116 | ) { _, e2 -> 117 | callbackWithToasts(e2, callback) 118 | } 119 | } 120 | } 121 | } 122 | 123 | /** 124 | * @hide 125 | * Send plain text notifications. 126 | * 127 | * Will be used in dev mode. 128 | */ 129 | private fun sendPlainTextNotification(callback: (error: VolleyError?) -> Unit) { 130 | val requestQueue: RequestQueue = Volley.newRequestQueue(context) 131 | val url = Store(context).endpoint 132 | val stringRequest: StringRequest = 133 | object : 134 | StringRequest( 135 | Method.POST, 136 | url, 137 | Response.Listener { 138 | callback(null) 139 | }, 140 | Response.ErrorListener(callback) 141 | ) { 142 | override fun getParams(): MutableMap { 143 | val params = mutableMapOf() 144 | params["title"] = "Test" 145 | params["message"] = "Send in cleartext." 146 | params["priority"] = "5" 147 | return params 148 | } 149 | } 150 | requestQueue.add(stringRequest) 151 | } 152 | 153 | /** 154 | * Send a notification encrypted with RFC8291 155 | */ 156 | private fun sendWebPushNotification( 157 | content: String, 158 | fakeKeys: Boolean, 159 | topic: String? = null, 160 | ttl: Int = 5, 161 | callback: (response: NetworkResponse?, error: VolleyError?) -> Unit 162 | ) { 163 | val requestQueue: RequestQueue = Volley.newRequestQueue(context) 164 | val url = Store(context).endpoint 165 | val request = 166 | object : 167 | RawRequest( 168 | Method.POST, 169 | url, 170 | Response.Listener { r -> 171 | callback(r, null) 172 | }, 173 | Response.ErrorListener { e -> 174 | callback(null, e) 175 | } 176 | ) { 177 | override fun getBody(): ByteArray { 178 | val auth = 179 | if (fakeKeys) { 180 | genAuth() 181 | } else { 182 | store.b64authSecret?.b64decode() 183 | } 184 | val hybridEncrypt = 185 | WebPushHybridEncrypt.Builder() 186 | .withAuthSecret(auth) 187 | .withRecipientPublicKey(store.serializedPubKey?.decodePubKey() as ECPublicKey) 188 | .build() 189 | return hybridEncrypt.encrypt(content.toByteArray(), null) 190 | } 191 | 192 | override fun getHeaders(): Map { 193 | val params: MutableMap = HashMap() 194 | params["Content-Encoding"] = "aes128gcm" 195 | params["TTL"] = "$ttl" 196 | params["Urgency"] = if (store.devMode) store.urgency.value else Urgency.HIGH.value 197 | topic?.let { 198 | params["Topic"] = it 199 | } 200 | if (vapidImplementedForSdk() && 201 | ( 202 | (store.devMode && store.devUseVapid) || 203 | store.distributorRequiresVapid 204 | ) 205 | ) { 206 | params["Authorization"] = getVapidHeader(fakeKeys = (store.devMode && store.devWrongVapidKeysTest)) 207 | } 208 | return params 209 | } 210 | } 211 | requestQueue.add(request) 212 | } 213 | 214 | private fun genAuth(): ByteArray { 215 | return ByteArray(16).apply { 216 | SecureRandom().nextBytes(this) 217 | } 218 | } 219 | 220 | /** 221 | * Emulate saving the endpoint on the application server. 222 | */ 223 | fun storeEndpoint(endpoint: String?) { 224 | store.endpoint = endpoint 225 | } 226 | 227 | /** 228 | * Emulate saving the web push public keys on the application server. 229 | */ 230 | fun storeWebPushKeys(auth: String, p256dh: String) { 231 | store.b64authSecret = auth 232 | store.serializedPubKey = p256dh 233 | } 234 | 235 | /** 236 | * Get the VAPID header for the endpoint, from cache or generate a new one 237 | * 238 | * - The header is cached for 5 minutes: following RFC8292, push servers should 239 | * cache the JWT to avoid checking the signature every time. This allows to test 240 | * it. 241 | * - The header is valid for 12h to allow manually testing the server from CLI 242 | * 243 | * This is for the `Authorization` header. 244 | * 245 | * @return [String] "vapid t=$JWT,k=$PUBKEY" 246 | */ 247 | @RequiresApi(Build.VERSION_CODES.M) 248 | fun getVapidHeader(fakeKeys: Boolean = false): String { 249 | val endpoint = store.endpoint ?: return "" 250 | val vapidCache = vapidCache 251 | return if ( 252 | !fakeKeys && 253 | vapidCache != null && 254 | vapidCache.endpoint == endpoint && 255 | vapidCache.date.after(Calendar.getInstance()) 256 | ) { 257 | vapidCache.value 258 | } else { 259 | genVapidHeader(endpoint, fakeKeys).also { vapid -> 260 | if (!fakeKeys) { 261 | Log.d(TAG, "Caching VAPID header: $vapid") 262 | ApplicationServer.vapidCache = VapidCache( 263 | endpoint = endpoint, 264 | date = Calendar.getInstance().apply { add(Calendar.MINUTE, 5) }, 265 | value = vapid 266 | ) 267 | } 268 | } 269 | } 270 | } 271 | 272 | /** 273 | * Generate VAPID header for the endpoint, valid for 12h 274 | * 275 | * This is for the `Authorization` header. 276 | * 277 | * @return [String] "vapid t=$JWT,k=$PUBKEY" 278 | */ 279 | @RequiresApi(Build.VERSION_CODES.M) 280 | fun genVapidHeader(endpointStr: String, fakeKeys: Boolean = false): String { 281 | val header = 282 | JSONObject() 283 | .put("alg", "ES256") 284 | .put("typ", "JWT") 285 | .toString().toByteArray(Charsets.UTF_8) 286 | .b64encode() 287 | val endpoint = URL(endpointStr) 288 | val time12h = ((System.currentTimeMillis() / 1000) + 43200) // +12h 289 | 290 | /** 291 | * [org.json.JSONStringer#string] Doesn't follow RFC, '/' = 0x2F doesn't have to be escaped 292 | */ 293 | val body = 294 | JSONObject() 295 | .put("aud", "${endpoint.protocol}://${endpoint.authority}") 296 | .put("exp", time12h) 297 | .put("sub", "https://codeberg.org/UnifiedPush/android-example") 298 | .toString() 299 | .replace("\\/", "/") 300 | .toByteArray(Charsets.UTF_8) 301 | .b64encode() 302 | val toSign = "$header.$body".toByteArray(Charsets.UTF_8) 303 | val signature = 304 | ( 305 | if (fakeKeys) { 306 | signWithTempKey(toSign) 307 | } else { 308 | sign(toSign) 309 | } 310 | )?.b64encode() ?: "" 311 | val jwt = "$header.$body.$signature" 312 | return "vapid t=$jwt,k=${store.vapidPubKey}" 313 | } 314 | 315 | /** 316 | * Generate a new KeyPair for VAPID on the fake server side 317 | */ 318 | @RequiresApi(Build.VERSION_CODES.M) 319 | fun genVapidKey(): KeyPair { 320 | Log.d(TAG, "Generating a new KP.") 321 | val generator = 322 | KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC, KEYSTORE_PROVIDER) 323 | generator.initialize( 324 | KeyGenParameterSpec.Builder(ALIAS, KeyProperties.PURPOSE_SIGN) 325 | .setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1")) 326 | .setDigests(KeyProperties.DIGEST_SHA256) 327 | .setUserAuthenticationRequired(false) 328 | .build() 329 | ) 330 | return generator.generateKeyPair().also { 331 | val pubkey = (it.public as ECPublicKey).encode() 332 | Log.d(TAG, "Pubkey: $pubkey") 333 | store.vapidPubKey = pubkey 334 | } 335 | } 336 | 337 | @RequiresApi(Build.VERSION_CODES.M) 338 | fun updateVapidKey(): KeyPair { 339 | KeyStore.getInstance(KEYSTORE_PROVIDER).apply { 340 | load(null) 341 | }.deleteEntry(ALIAS) 342 | return genVapidKey() 343 | } 344 | 345 | /** 346 | * Sign [data] using the generated VAPID key pair 347 | */ 348 | @RequiresApi(Build.VERSION_CODES.M) 349 | private fun sign(data: ByteArray): ByteArray? { 350 | val ks = 351 | KeyStore.getInstance(KEYSTORE_PROVIDER).apply { 352 | load(null) 353 | } 354 | if (!ks.containsAlias(ALIAS) || !ks.entryInstanceOf(ALIAS, PrivateKeyEntry::class.java)) { 355 | // This should never be called. When we sign something, the key are already created. 356 | genVapidKey() 357 | } 358 | val entry: KeyStore.Entry = ks.getEntry(ALIAS, null) 359 | if (entry !is PrivateKeyEntry) { 360 | Log.w(TAG, "Not an instance of a PrivateKeyEntry") 361 | return null 362 | } 363 | // printX509pub(entry.certificate.publicKey) 364 | return Signature.getInstance("SHA256withECDSA").run { 365 | initSign(entry.privateKey) 366 | update(data) 367 | sign() 368 | }.let { EllipticCurves.ecdsaDer2Ieee(it, 64) } 369 | } 370 | 371 | private fun signWithTempKey(data: ByteArray): ByteArray? { 372 | val keyPair: KeyPair = 373 | EllipticCurves.generateKeyPair(EllipticCurves.CurveType.NIST_P256) 374 | // printX509pub(keyPair.public) 375 | // printX509priv(keyPair.private) 376 | return Signature.getInstance("SHA256withECDSA").run { 377 | initSign(keyPair.private) 378 | update(data) 379 | sign() 380 | }.let { EllipticCurves.ecdsaDer2Ieee(it, 64) } 381 | } 382 | 383 | /* 384 | private fun printX509pub(pubkey: PublicKey) { 385 | val b64 = Base64.encode(pubkey.encoded, Base64.DEFAULT).toString(Charsets.UTF_8) 386 | Log.d(TAG, "-----BEGIN PUBLIC KEY-----\n$b64-----END PUBLIC KEY-----") 387 | } 388 | 389 | private fun printX509priv(privkey: PrivateKey) { 390 | val b64 = Base64.encode(privkey.encoded, Base64.DEFAULT).toString(Charsets.UTF_8) 391 | Log.d(TAG, "-----BEGIN PRIVATE KEY-----\n$b64-----END PRIVATE KEY-----") 392 | } 393 | */ 394 | 395 | data class VapidCache( 396 | val endpoint: String, 397 | val date: Calendar, 398 | val value: String 399 | ) 400 | 401 | private companion object { 402 | private const val KEYSTORE_PROVIDER = "AndroidKeyStore" 403 | private const val ALIAS = "ApplicationServer" 404 | private var vapidCache: VapidCache? = null 405 | } 406 | } 407 | -------------------------------------------------------------------------------- /app/src/main/java/org/unifiedpush/example/PushServiceImpl.kt: -------------------------------------------------------------------------------- 1 | package org.unifiedpush.example 2 | 3 | import android.content.Context 4 | import android.os.Build 5 | import android.util.Log 6 | import android.widget.Toast 7 | import org.unifiedpush.android.connector.FailedReason 8 | import org.unifiedpush.android.connector.PushService 9 | import org.unifiedpush.android.connector.UnifiedPush 10 | import org.unifiedpush.android.connector.data.PushEndpoint 11 | import org.unifiedpush.android.connector.data.PushMessage 12 | import org.unifiedpush.example.activities.Events 13 | import org.unifiedpush.example.utils.Notifier 14 | import org.unifiedpush.example.utils.TAG 15 | import org.unifiedpush.example.utils.decodeMessage 16 | import org.unifiedpush.example.utils.vapidImplementedForSdk 17 | 18 | class PushServiceImpl : PushService() { 19 | private val context = this 20 | override fun onMessage(message: PushMessage, instance: String) { 21 | val store = Store(context) 22 | if (!store.devMode) { 23 | val params = decodeMessage(message.content.toString(Charsets.UTF_8)) 24 | notify(context, params) 25 | } else { 26 | // For developer mode only 27 | val params = 28 | if (store.devForceEncrypted && !message.decrypted) { 29 | mapOf( 30 | "title" to "Error", 31 | "message" to "Couldn't decrypt message.", 32 | "priority" to "8" 33 | ) 34 | } else { 35 | decodeMessage(message.content.toString(Charsets.UTF_8)) 36 | } 37 | notify(context, params) 38 | if (store.devStartForeground && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { 39 | TestService.startForeground(context) 40 | Events.emit(Events.Type.UpdateUi) 41 | } 42 | } 43 | } 44 | 45 | private fun notify(context: Context, params: Map) { 46 | val text = params["message"] ?: "Internal error" 47 | val priority = params["priority"]?.toInt() ?: 8 48 | val title = params["title"] ?: context.getString(R.string.app_name) 49 | Notifier(context).showNotification(title, text, priority) 50 | } 51 | 52 | override fun onNewEndpoint(endpoint: PushEndpoint, instance: String) { 53 | Log.d(TAG, "New Endpoint: ${endpoint.url}") 54 | ApplicationServer(context).storeEndpoint(endpoint.url) 55 | endpoint.pubKeySet?.let { 56 | ApplicationServer(context).storeWebPushKeys( 57 | it.auth, 58 | it.pubKey 59 | ) 60 | } 61 | Events.emit(Events.Type.UpdateUi) 62 | } 63 | 64 | override fun onRegistrationFailed(reason: FailedReason, instance: String) { 65 | Toast.makeText(context, "Registration Failed: $reason", Toast.LENGTH_SHORT).show() 66 | if (reason == FailedReason.VAPID_REQUIRED) { 67 | if (vapidImplementedForSdk()) { 68 | val store = Store(context) 69 | store.distributorRequiresVapid = true 70 | ApplicationServer(context).genVapidKey() 71 | UnifiedPush.register(context, instance, vapid = store.vapidPubKey) 72 | } else { 73 | Toast.makeText( 74 | context, 75 | "Distributor requires VAPID but it isn't implemented for old Android versions.", 76 | Toast.LENGTH_SHORT 77 | ).show() 78 | UnifiedPush.removeDistributor(context) 79 | } 80 | } else { 81 | UnifiedPush.removeDistributor(context) 82 | } 83 | } 84 | 85 | override fun onUnregistered(instance: String) { 86 | // Remove the endpoint on the application server 87 | ApplicationServer(context).storeEndpoint(null) 88 | Events.emit(Events.Type.UpdateUi) 89 | val appName = context.getString(R.string.app_name) 90 | Toast.makeText(context, "$appName is unregistered", Toast.LENGTH_SHORT).show() 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /app/src/main/java/org/unifiedpush/example/Store.kt: -------------------------------------------------------------------------------- 1 | package org.unifiedpush.example 2 | 3 | import android.content.Context 4 | import org.unifiedpush.android.connector.UnifiedPush 5 | 6 | private const val PREF_MASTER = "org.unifiedpush.example::store" 7 | private const val PREF_DEV_MODE = "org.unifiedpush.example::store::devMode" 8 | private const val PREF_DEV_FOREGROUND_SERVICE = "org.unifiedpush.example::store::dev::foregroundService" 9 | private const val PREF_DEV_CLEARTEXT_TEST = "org.unifiedpush.example::store::dev::cleartextTest" 10 | private const val PREF_DEV_WRONG_KEYS_TEST = "org.unifiedpush.example::store::dev::wrongKeysTest" 11 | private const val PREF_DEV_WRONG_VAPID_KEYS = "org.unifiedpush.example::store::dev::wrongVapidKeysTest" 12 | private const val PREF_DEV_FORCE_ENCRYPTED = "org.unifiedpush.example::store::dev::forceEncrypted" 13 | private const val PREF_DEV_USE_VAPID = "org.unifiedpush.example::store::dev::useVapid" 14 | private const val PREF_ENDPOINT = "org.unifiedpush.example::store::endpoint" 15 | private const val PREF_URGENCY = "org.unifiedpush.example::store::urgency" 16 | private const val PREF_PUBKEY = "org.unifiedpush.example::store::pubkey" 17 | private const val PREF_AUTHKEY = "org.unifiedpush.example::store::authkey" 18 | private const val PREF_DISTRIB_REQ_VAPID = "org.unifiedpush.example::store::distribRequiresVapid" 19 | private const val PREF_VAPID_PUBKEY = "org.unifiedpush.example::store::vapidPubKey" 20 | 21 | /** 22 | * Class containing stored parameters and values. 23 | */ 24 | class Store(val context: Context) { 25 | private val prefs = context.getSharedPreferences(PREF_MASTER, Context.MODE_PRIVATE) 26 | 27 | /** Is Developer mode enabled. */ 28 | var devMode: Boolean 29 | get() = prefs.getBoolean(PREF_DEV_MODE, false) 30 | set(value) = prefs.edit().putBoolean(PREF_DEV_MODE, value).apply() 31 | 32 | /** For developer mode: Start foreground service when a push message is received. */ 33 | var devStartForeground: Boolean 34 | get() = prefs.getBoolean(PREF_DEV_FOREGROUND_SERVICE, false) 35 | set(value) = prefs.edit().putBoolean(PREF_DEV_FOREGROUND_SERVICE, value).apply() 36 | 37 | /** For developer mode: Send unencrypted push messages. */ 38 | var devCleartextTest: Boolean 39 | get() = prefs.getBoolean(PREF_DEV_CLEARTEXT_TEST, false) 40 | set(value) = prefs.edit().putBoolean(PREF_DEV_CLEARTEXT_TEST, value).apply() 41 | 42 | /** For developer mode: Use wrong encryption key to send push messages. */ 43 | var devWrongKeysTest: Boolean 44 | get() = prefs.getBoolean(PREF_DEV_WRONG_KEYS_TEST, false) 45 | set(value) = prefs.edit().putBoolean(PREF_DEV_WRONG_KEYS_TEST, value).apply() 46 | 47 | /** For developer mode: Use wrong VAPID key with push messages.*/ 48 | var devWrongVapidKeysTest: Boolean 49 | get() = prefs.getBoolean(PREF_DEV_WRONG_VAPID_KEYS, false) 50 | set(value) = prefs.edit().putBoolean(PREF_DEV_WRONG_VAPID_KEYS, value).apply() 51 | 52 | /** 53 | * For developer mode: Show an error notification when receiving an unencrypted push message. 54 | * 55 | * When [PushMessage.decrypted][org.unifiedpush.android.connector.data.PushMessage.decrypted] 56 | * is false. 57 | */ 58 | var devForceEncrypted: Boolean 59 | get() = prefs.getBoolean(PREF_DEV_FORCE_ENCRYPTED, false) 60 | set(value) = prefs.edit().putBoolean(PREF_DEV_FORCE_ENCRYPTED, value).apply() 61 | 62 | /** For developer mode: Use VAPID even if the distributor doesn't require it. */ 63 | var devUseVapid: Boolean 64 | get() = prefs.getBoolean(PREF_DEV_USE_VAPID, false) 65 | set(value) = prefs.edit().putBoolean(PREF_DEV_USE_VAPID, value).apply() 66 | 67 | /** Push endpoint. Should be saved on application server. */ 68 | var endpoint: String? 69 | get() = UnifiedPush.getAckDistributor(context)?.let { prefs.getString(PREF_ENDPOINT, null) } 70 | set(value) { 71 | if (value == null) { 72 | prefs.edit().remove(PREF_ENDPOINT).apply() 73 | } else { 74 | prefs.edit().putString(PREF_ENDPOINT, value).apply() 75 | } 76 | } 77 | 78 | /** For developer mode: Urgency for push messages. Should be chosen by the application server. */ 79 | var urgency: Urgency 80 | get() = Urgency.fromValue(prefs.getString(PREF_URGENCY, null)) 81 | set(value) = prefs.edit().putString(PREF_URGENCY, value.value).apply() 82 | 83 | /** WebPush public key. Should be saved on application server. */ 84 | var serializedPubKey: String? 85 | get() = prefs.getString(PREF_PUBKEY, null) 86 | set(value) = prefs.edit().putString(PREF_PUBKEY, value).apply() 87 | 88 | /** Does the distributor requires VAPID ? It should always be used if the application server supports it. */ 89 | var distributorRequiresVapid: Boolean 90 | get() = prefs.getBoolean(PREF_DISTRIB_REQ_VAPID, false) 91 | set(value) = prefs.edit().putBoolean(PREF_DISTRIB_REQ_VAPID, value).apply() 92 | 93 | /** VAPID public key. Should be saved on application server. */ 94 | var vapidPubKey: String? 95 | get() = prefs.getString(PREF_VAPID_PUBKEY, null) 96 | set(value) = prefs.edit().putString(PREF_VAPID_PUBKEY, value).apply() 97 | 98 | /** WebPush auth secret. Should be saved on application server. */ 99 | var b64authSecret: String? 100 | get() = prefs.getString(PREF_AUTHKEY, null) 101 | set(value) = prefs.edit().putString(PREF_AUTHKEY, value).apply() 102 | } 103 | -------------------------------------------------------------------------------- /app/src/main/java/org/unifiedpush/example/TestService.kt: -------------------------------------------------------------------------------- 1 | package org.unifiedpush.example 2 | 3 | import android.app.Notification 4 | import android.app.NotificationChannel 5 | import android.app.NotificationManager 6 | import android.app.Service 7 | import android.content.Context 8 | import android.content.Intent 9 | import android.os.Build 10 | import android.os.IBinder 11 | import android.util.Log 12 | import androidx.annotation.RequiresApi 13 | import kotlin.concurrent.Volatile 14 | import org.unifiedpush.example.activities.Events 15 | 16 | /** 17 | * Service to test [foreground services](https://developer.android.com/develop/background-work/services/foreground-services) started from the background. 18 | * 19 | * It isn't possible to start a foreground service from the background except if: 20 | * - the application is unrestricted for battery use 21 | * - the application has been set in saving power whitelist for some time (this happens after a FCM urgent push message) 22 | * - the application is in foreground importance (a distributor can bring an application to foreground important by binding to the connector dedicated service) 23 | */ 24 | class TestService : Service() { 25 | override fun onBind(intent: Intent?): IBinder? { 26 | Log.d(TAG, "Bound") 27 | return null 28 | } 29 | 30 | override fun onCreate() { 31 | super.onCreate() 32 | Log.i(TAG, "Created") 33 | createNotificationChannel() 34 | val notification = createNotification() 35 | startForeground(NOTIFICATION_ID_FOREGROUND, notification) 36 | } 37 | 38 | override fun onDestroy() { 39 | super.onDestroy() 40 | Log.i(TAG, "Destroyed") 41 | } 42 | 43 | private fun createNotificationChannel() { 44 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { 45 | val notificationManager = 46 | this.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager 47 | val channel = 48 | NotificationChannel( 49 | CHANNEL_ID, 50 | "TestService", 51 | NotificationManager.IMPORTANCE_HIGH 52 | ).let { 53 | it.description = "test" 54 | it 55 | } 56 | notificationManager.createNotificationChannel(channel) 57 | } 58 | } 59 | 60 | private fun createNotification(): Notification { 61 | val builder: Notification.Builder = 62 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { 63 | Notification.Builder( 64 | this, 65 | CHANNEL_ID 66 | ) 67 | } else { 68 | Notification.Builder(this) 69 | } 70 | 71 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { 72 | builder.setForegroundServiceBehavior(Notification.FOREGROUND_SERVICE_IMMEDIATE) 73 | } 74 | 75 | return builder 76 | .setContentTitle("TestService") 77 | .setContentText("Run in foreground") 78 | .setSmallIcon(R.drawable.ic_launcher_foreground) 79 | .setTicker("foo") 80 | .setOngoing(true) 81 | .build() 82 | } 83 | 84 | companion object { 85 | /** Start [TestService] in the foreground */ 86 | @RequiresApi(Build.VERSION_CODES.O) 87 | fun startForeground(context: Context) { 88 | synchronized(lock) { 89 | val intent = Intent(context, TestService::class.java) 90 | context.startForegroundService(intent) 91 | started = true 92 | } 93 | Events.emit(Events.Type.UpdateUi) 94 | } 95 | 96 | /** Stop [TestService] */ 97 | fun stop(context: Context) { 98 | synchronized(lock) { 99 | val intent = Intent(context, TestService::class.java) 100 | context.stopService(intent) 101 | started = false 102 | } 103 | Events.emit(Events.Type.UpdateUi) 104 | } 105 | 106 | /** Is the foreground test service running ? */ 107 | fun isStarted(): Boolean = synchronized(lock) { 108 | started 109 | } 110 | 111 | @Volatile 112 | private var started = false 113 | private var lock = Object() 114 | private const val CHANNEL_ID = "TestService:ChannelId" 115 | private const val NOTIFICATION_ID_FOREGROUND = 0x1000 116 | private const val TAG = "TestService" 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /app/src/main/java/org/unifiedpush/example/Tests.kt: -------------------------------------------------------------------------------- 1 | package org.unifiedpush.example 2 | 3 | import android.app.Activity 4 | import android.widget.Toast 5 | import androidx.appcompat.app.AlertDialog 6 | import java.util.Timer 7 | import kotlin.concurrent.schedule 8 | import org.unifiedpush.example.utils.DelayedRequestWorker 9 | 10 | class Tests(private val activity: Activity) { 11 | fun testTTL(callback: (error: String?) -> Unit) { 12 | testTTLIntro { 13 | ApplicationServer(activity).sendTestTTLNotification { e -> 14 | Toast.makeText(activity, "Notification sent.", Toast.LENGTH_SHORT).show() 15 | e?. run { 16 | Toast.makeText(activity, "TTL not (fully) supported.", Toast.LENGTH_SHORT).show() 17 | } ?: run { 18 | Thread.dumpStack() 19 | Timer().schedule(10_000L) { 20 | activity.runOnUiThread { 21 | Toast.makeText( 22 | activity, 23 | "You can reconnect your distributor.", 24 | Toast.LENGTH_SHORT 25 | ).show() 26 | } 27 | } 28 | } 29 | callback(e) 30 | } 31 | } 32 | } 33 | 34 | private fun testTTLIntro(onSuccess: () -> Unit) { 35 | val builder: AlertDialog.Builder = AlertDialog.Builder(activity) 36 | builder.setTitle("Testing TTL") 37 | builder.setMessage( 38 | "To check the TTL, you must first disconnect your distributor.\n" + 39 | "You can reconnect it after 10 seconds.\n" + 40 | "A notification will be sent, it should not be displayed by the application.\n\n" + 41 | "Press OK once the distributor is disconnected." 42 | ) 43 | builder.setPositiveButton(android.R.string.ok) { _, _ -> 44 | onSuccess() 45 | } 46 | builder.setNegativeButton(android.R.string.cancel) { _, _ -> 47 | Toast.makeText(activity, "Aborting", Toast.LENGTH_SHORT).show() 48 | } 49 | builder.create().show() 50 | } 51 | 52 | fun testTopic(callback: (error: String?) -> Unit) { 53 | testTopicIntro { 54 | ApplicationServer(activity).sendTestTopicNotifications { e -> 55 | Toast.makeText(activity, "Notifications sent.", Toast.LENGTH_SHORT).show() 56 | e ?: run { 57 | Thread.dumpStack() 58 | Timer().schedule(10_000L) { 59 | activity.runOnUiThread { 60 | Toast.makeText( 61 | activity, 62 | "You can reconnect your distributor.", 63 | Toast.LENGTH_SHORT 64 | ).show() 65 | } 66 | } 67 | } 68 | callback(e) 69 | } 70 | } 71 | } 72 | 73 | private fun testTopicIntro(onSuccess: () -> Unit) { 74 | val builder: AlertDialog.Builder = AlertDialog.Builder(activity) 75 | builder.setTitle("Testing Topic") 76 | builder.setMessage( 77 | "To check topics, you must first disconnect your distributor.\n" + 78 | "You can reconnect it after 10 seconds.\n" + 79 | "2 notifications will be sent, only the 2nd one should be displayed by the application.\n\n" + 80 | "If you see 2 notifications, your distributor doesn't support notification update.\n\n" + 81 | "Press OK once the distributor is disconnected." 82 | ) 83 | builder.setPositiveButton(android.R.string.ok) { _, _ -> 84 | onSuccess() 85 | } 86 | builder.setNegativeButton(android.R.string.cancel) { _, _ -> 87 | Toast.makeText(activity, "Aborting", Toast.LENGTH_SHORT).show() 88 | } 89 | builder.create().show() 90 | } 91 | 92 | /** 93 | * Display introduction for test sending a notification while the app is in background. 94 | * 95 | * Calls [testMessageInBackgroundIntro] and set [runBackgroundCheck] to `true`. 96 | * 97 | * It requires the user to put the application in background and [testMessageInBackgroundRun] 98 | * to be called in [Activity.onPause]. 99 | */ 100 | fun testMessageInBackgroundStart() { 101 | testMessageInBackgroundIntro { runBackgroundCheck = true } 102 | } 103 | 104 | private fun testMessageInBackgroundIntro(onSuccess: () -> Unit) { 105 | val builder: AlertDialog.Builder = AlertDialog.Builder(activity) 106 | builder.setTitle("Testing notifications in background") 107 | builder.setMessage( 108 | "To check, you need to put this application in the background.\n" + 109 | "A notification will be sent after 7 seconds.\n\n" + 110 | "It's also possible to put the application in the background and send unencrypted " + 111 | "POST message to the endpoint via a terminal to test foreground services.\n\n" + 112 | "Press OK to continue." 113 | ) 114 | builder.setPositiveButton(android.R.string.ok) { _, _ -> 115 | Toast.makeText(activity, "Notification will be sent in the background", Toast.LENGTH_SHORT).show() 116 | onSuccess() 117 | } 118 | builder.setNegativeButton(android.R.string.cancel) { _, _ -> 119 | Toast.makeText(activity, "Aborting", Toast.LENGTH_SHORT).show() 120 | } 121 | builder.create().show() 122 | } 123 | 124 | /** 125 | * Send a notification after 5 seconds. 126 | * 127 | * Should be called in [Activity.onPause] 128 | */ 129 | fun testMessageInBackgroundRun() { 130 | if (runBackgroundCheck) { 131 | runBackgroundCheck = false 132 | DelayedRequestWorker.enqueue(activity, 7_000L) 133 | } 134 | } 135 | 136 | private companion object { 137 | var runBackgroundCheck = false 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /app/src/main/java/org/unifiedpush/example/Urgency.kt: -------------------------------------------------------------------------------- 1 | package org.unifiedpush.example 2 | 3 | /** 4 | * Define in [RFC8030](https://www.rfc-editor.org/rfc/rfc8030#section-5.3) 5 | * +----------+-----------------------------+--------------------------+ 6 | * | Urgency | Device State | Example Application | 7 | * | | | Scenario | 8 | * +----------+-----------------------------+--------------------------+ 9 | * | very-low | On power and Wi-Fi | Advertisements | 10 | * | low | On either power or Wi-Fi | Topic updates | 11 | * | normal | On neither power nor Wi-Fi | Chat or Calendar Message | 12 | * | high | Low battery | Incoming phone call or | 13 | * | | | time-sensitive alert | 14 | * +----------+-----------------------------+--------------------------+ 15 | */ 16 | enum class Urgency(val value: String) { 17 | /** On power and Wi-Fi, example: Advertisements */ 18 | VERY_LOW("very-low"), 19 | 20 | /** On either power or Wi-Fi, example: Topic updates */ 21 | LOW("low"), 22 | 23 | /** On neither power nor Wi-Fi, example: Chat or Calendar Message */ 24 | NORMAL("normal"), 25 | 26 | /** Low battery, example: Incoming phone call or time-sensitive alert */ 27 | HIGH("high") ; 28 | 29 | companion object { 30 | fun fromValue(s: String?): Urgency { 31 | return Urgency.entries.find { it.value == s } ?: Urgency.NORMAL 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /app/src/main/java/org/unifiedpush/example/activities/AppBarViewModel.kt: -------------------------------------------------------------------------------- 1 | package org.unifiedpush.example.activities 2 | 3 | import android.content.Context 4 | import androidx.compose.runtime.getValue 5 | import androidx.compose.runtime.mutableStateOf 6 | import androidx.compose.runtime.setValue 7 | import androidx.lifecycle.ViewModel 8 | import org.unifiedpush.example.Store 9 | import org.unifiedpush.example.activities.ui.AppBarUiState 10 | 11 | class AppBarViewModel( 12 | uiState: AppBarUiState, 13 | context: Context? = null 14 | ) : ViewModel() { 15 | 16 | var uiState by mutableStateOf(uiState) 17 | private set 18 | 19 | private var store: Store? = context?.let { Store(context) } 20 | 21 | constructor(context: Context) : this( 22 | uiState = AppBarUiState.from(context), 23 | context = context 24 | ) 25 | 26 | fun toggleDevMode() { 27 | uiState = uiState.copy(devMode = !uiState.devMode) 28 | store?.devMode = uiState.devMode 29 | Events.emit(Events.Type.UpdateUi) 30 | } 31 | 32 | fun toggleErrorIfDecryptionFail() { 33 | uiState = uiState.copy(errorIfDecryptionFail = !uiState.errorIfDecryptionFail) 34 | store?.devForceEncrypted = uiState.errorIfDecryptionFail 35 | } 36 | 37 | fun toggleUseVapid() { 38 | uiState = uiState.copy(useVapid = !uiState.useVapid) 39 | store?.devUseVapid = uiState.useVapid 40 | Events.emit(Events.Type.UpdateUi) 41 | } 42 | 43 | fun toggleSendClearTextTests() { 44 | uiState = uiState.copy(sendClearTextTests = !uiState.sendClearTextTests) 45 | store?.devCleartextTest = uiState.sendClearTextTests 46 | Events.emit(Events.Type.UpdateUi) 47 | } 48 | 49 | fun toggleUseWrongVapidKeys() { 50 | uiState = uiState.copy(useWrongVapidKeys = !uiState.useWrongVapidKeys) 51 | store?.devWrongVapidKeysTest = uiState.useWrongVapidKeys 52 | } 53 | 54 | fun toggleUseWrongEncryptionKeys() { 55 | uiState = uiState.copy(useWrongEncryptionKeys = !uiState.useWrongEncryptionKeys) 56 | store?.devWrongKeysTest = uiState.useWrongEncryptionKeys 57 | Events.emit(Events.Type.UpdateUi) 58 | } 59 | 60 | fun toggleStartForegroundServiceOnMessage() { 61 | uiState = uiState.copy(startForegroundServiceOnMessage = !uiState.startForegroundServiceOnMessage) 62 | store?.devStartForeground = uiState.startForegroundServiceOnMessage 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /app/src/main/java/org/unifiedpush/example/activities/CheckActivity.kt: -------------------------------------------------------------------------------- 1 | package org.unifiedpush.example.activities 2 | 3 | import android.content.Context 4 | import android.content.Intent 5 | import android.os.Bundle 6 | import android.util.Log 7 | import android.widget.Toast 8 | import androidx.activity.ComponentActivity 9 | import androidx.activity.compose.setContent 10 | import kotlinx.coroutines.Job 11 | import org.unifiedpush.android.connector.LinkActivityHelper 12 | import org.unifiedpush.android.connector.UnifiedPush 13 | import org.unifiedpush.example.ApplicationServer 14 | import org.unifiedpush.example.TestService 15 | import org.unifiedpush.example.Tests 16 | import org.unifiedpush.example.activities.ui.CheckUi 17 | import org.unifiedpush.example.activities.ui.theme.AppTheme 18 | import org.unifiedpush.example.utils.RegistrationDialogs 19 | import org.unifiedpush.example.utils.TAG 20 | import org.unifiedpush.example.utils.vapidImplementedForSdk 21 | 22 | class CheckActivity : ComponentActivity() { 23 | private lateinit var appBarViewModel: AppBarViewModel 24 | private lateinit var checkViewModel: CheckViewModel 25 | private val helper = LinkActivityHelper(this) 26 | private var job: Job? = null 27 | 28 | override fun onCreate(savedInstanceState: Bundle?) { 29 | super.onCreate(savedInstanceState) 30 | appBarViewModel = AppBarViewModel(this) 31 | checkViewModel = CheckViewModel(this) 32 | 33 | job = Events.registerForEvents { onEvent(it) } 34 | 35 | setContent { 36 | AppTheme { 37 | CheckUi(appBarViewModel, checkViewModel) 38 | } 39 | } 40 | } 41 | 42 | override fun onResume() { 43 | super.onResume() 44 | updateUi() 45 | } 46 | 47 | override fun onDestroy() { 48 | job?.cancel() 49 | job = null 50 | super.onDestroy() 51 | } 52 | 53 | override fun onPause() { 54 | super.onPause() 55 | Tests(this).testMessageInBackgroundRun() 56 | } 57 | 58 | /** 59 | * Receive link activity result. 60 | * 61 | * If the link succeed, we register our app. 62 | */ 63 | override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { 64 | super.onActivityResult(requestCode, resultCode, data) 65 | val success = helper.onLinkActivityResult(requestCode, resultCode, data) 66 | Log.d(TAG, "Distributor found=$success") 67 | if (success) { 68 | UnifiedPush.register(this) 69 | } 70 | } 71 | 72 | private fun onEvent(type: Events.Type) { 73 | runOnUiThread { 74 | when (type) { 75 | Events.Type.UpdateUi -> updateUi() 76 | Events.Type.Unregister -> unregister() 77 | Events.Type.SendNotification -> sendNotification() 78 | Events.Type.DeepLink -> deepLink() 79 | Events.Type.Reregister -> reRegister() 80 | Events.Type.StopForegroundService -> stopForegroundService() 81 | Events.Type.ChangeDistributor -> changeDistributor() 82 | Events.Type.TestTopic -> testTopic() 83 | Events.Type.UpdateVapidKey -> updateVapidKey() 84 | Events.Type.TestInBackground -> testInBackground() 85 | Events.Type.TestTTL -> testTTL() 86 | else -> {} 87 | } 88 | } 89 | } 90 | 91 | private fun updateUi() { 92 | if (!checkViewModel.refresh(this)) { 93 | MainActivity.goToMainActivity(this) 94 | finish() 95 | } 96 | } 97 | 98 | private fun unregister() { 99 | Toast.makeText(this, "Unregistering", Toast.LENGTH_SHORT).show() 100 | ApplicationServer(this).storeEndpoint(null) 101 | UnifiedPush.unregister(this) 102 | UnifiedPush.removeDistributor(this) 103 | updateUi() 104 | } 105 | 106 | private fun sendNotification() { 107 | ApplicationServer(this).sendNotification { checkViewModel.setError(it) } 108 | } 109 | 110 | private fun deepLink() { 111 | /** 112 | * We use the [LinkActivityHelper] with [onActivityResult], but we could 113 | * also use [UnifiedPush.tryUseDefaultDistributor] directly: 114 | * 115 | * ``` 116 | * UnifiedPush.tryUseDefaultDistributor(this) { success -> 117 | * Log.d(TAG, "Distributor found=$success") 118 | * } 119 | * ``` 120 | */ 121 | if (!helper.startLinkActivityForResult()) { 122 | Log.d(TAG, "No distributor found") 123 | } 124 | } 125 | 126 | private fun reRegister() { 127 | RegistrationDialogs(this, mayUseCurrent = true, mayUseDefault = true).run() 128 | Toast.makeText(applicationContext, "Registration sent.", Toast.LENGTH_SHORT).show() 129 | } 130 | 131 | private fun stopForegroundService() { 132 | TestService.stop(this) 133 | updateUi() 134 | } 135 | 136 | private fun changeDistributor() { 137 | RegistrationDialogs(this, mayUseCurrent = false, mayUseDefault = false).run() 138 | } 139 | 140 | private fun testTopic() { 141 | Tests(this).testTopic { checkViewModel.setError(it) } 142 | } 143 | 144 | private fun updateVapidKey() { 145 | if (vapidImplementedForSdk()) { 146 | ApplicationServer(this).updateVapidKey() 147 | updateUi() 148 | } 149 | } 150 | 151 | private fun testInBackground() { 152 | Tests(this).testMessageInBackgroundStart() 153 | } 154 | 155 | private fun testTTL() { 156 | Tests(this).testTTL { checkViewModel.setError(it) } 157 | } 158 | 159 | companion object { 160 | fun goToCheckActivity(context: Context) { 161 | Log.d(TAG, "Go to CheckActivity") 162 | val intent = 163 | Intent( 164 | context, 165 | CheckActivity::class.java 166 | ) 167 | context.startActivity(intent) 168 | } 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /app/src/main/java/org/unifiedpush/example/activities/CheckViewModel.kt: -------------------------------------------------------------------------------- 1 | package org.unifiedpush.example.activities 2 | 3 | import android.content.Context 4 | import androidx.compose.runtime.getValue 5 | import androidx.compose.runtime.mutableStateOf 6 | import androidx.compose.runtime.setValue 7 | import androidx.lifecycle.ViewModel 8 | import org.unifiedpush.example.Store 9 | import org.unifiedpush.example.Urgency 10 | import org.unifiedpush.example.activities.ui.CheckUiState 11 | 12 | class CheckViewModel( 13 | uiState: CheckUiState, 14 | context: Context? = null 15 | ) : ViewModel() { 16 | 17 | var uiState by mutableStateOf(uiState) 18 | private set 19 | 20 | private var store: Store? = context?.let { Store(context) } 21 | 22 | constructor(context: Context) : this( 23 | CheckUiState.from(context) ?: CheckUiState.error(), 24 | context 25 | ) 26 | 27 | /** 28 | * @return `true` if the app is still connected to the distributor 29 | */ 30 | fun refresh(context: Context): Boolean { 31 | return CheckUiState.from(context)?.let { 32 | uiState = it 33 | } != null 34 | } 35 | 36 | fun setError(error: String?) { 37 | uiState = uiState.copy(error = error) 38 | } 39 | 40 | fun setUrgency(urgency: Urgency) { 41 | uiState = uiState.copy(urgency = urgency) 42 | store?.urgency = urgency 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /app/src/main/java/org/unifiedpush/example/activities/Events.kt: -------------------------------------------------------------------------------- 1 | package org.unifiedpush.example.activities 2 | 3 | import kotlinx.coroutines.CoroutineScope 4 | import kotlinx.coroutines.Dispatchers 5 | import kotlinx.coroutines.Job 6 | import kotlinx.coroutines.flow.MutableSharedFlow 7 | import kotlinx.coroutines.flow.asSharedFlow 8 | import kotlinx.coroutines.flow.collectLatest 9 | import kotlinx.coroutines.launch 10 | 11 | object Events { 12 | fun registerForEvents(handler: (Type) -> Unit): Job { 13 | return CoroutineScope(Dispatchers.IO).launch { 14 | events.collectLatest { event -> 15 | handler(event) 16 | } 17 | } 18 | } 19 | 20 | suspend inline fun emitAsync(type: Type) { 21 | mutEvents.emit(type) 22 | } 23 | 24 | fun emit(type: Type) { 25 | CoroutineScope(Dispatchers.IO).launch { 26 | emitAsync(type) 27 | } 28 | } 29 | 30 | enum class Type { 31 | Register, 32 | UpdateUi, 33 | Unregister, 34 | SendNotification, 35 | DeepLink, 36 | Reregister, 37 | StopForegroundService, 38 | 39 | // SetUrgency, 40 | ChangeDistributor, 41 | TestTopic, 42 | UpdateVapidKey, 43 | TestInBackground, 44 | TestTTL 45 | } 46 | 47 | val mutEvents: MutableSharedFlow = MutableSharedFlow() 48 | private val events = mutEvents.asSharedFlow() 49 | } 50 | -------------------------------------------------------------------------------- /app/src/main/java/org/unifiedpush/example/activities/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package org.unifiedpush.example.activities 2 | 3 | import android.content.Context 4 | import android.content.Intent 5 | import android.os.Bundle 6 | import android.util.Log 7 | import androidx.activity.ComponentActivity 8 | import androidx.activity.compose.setContent 9 | import kotlinx.coroutines.Job 10 | import org.unifiedpush.example.Store 11 | import org.unifiedpush.example.activities.CheckActivity.Companion.goToCheckActivity 12 | import org.unifiedpush.example.activities.ui.MainUi 13 | import org.unifiedpush.example.activities.ui.theme.AppTheme 14 | import org.unifiedpush.example.utils.RegistrationDialogs 15 | import org.unifiedpush.example.utils.TAG 16 | 17 | class MainActivity : ComponentActivity() { 18 | private lateinit var appBarViewModel: AppBarViewModel 19 | private var job: Job? = null 20 | 21 | override fun onCreate(savedInstanceState: Bundle?) { 22 | super.onCreate(savedInstanceState) 23 | appBarViewModel = AppBarViewModel(this) 24 | 25 | job = Events.registerForEvents { onEvent(it) } 26 | 27 | setContent { 28 | AppTheme { 29 | MainUi(appBarViewModel) 30 | } 31 | } 32 | } 33 | 34 | override fun onResume() { 35 | super.onResume() 36 | val store = Store(this) 37 | if (store.endpoint != null) { 38 | goToCheckActivity(this) 39 | finish() 40 | } else { 41 | // We reset the value 42 | store.distributorRequiresVapid = false 43 | } 44 | } 45 | 46 | override fun onDestroy() { 47 | job?.cancel() 48 | job = null 49 | super.onDestroy() 50 | } 51 | 52 | private fun onEvent(type: Events.Type) { 53 | when (type) { 54 | Events.Type.Register -> { 55 | runOnUiThread { 56 | RegistrationDialogs(this, mayUseCurrent = true, mayUseDefault = true).run() 57 | } 58 | } 59 | Events.Type.UpdateUi -> { 60 | goToCheckActivity(this) 61 | finish() 62 | } 63 | else -> {} 64 | } 65 | } 66 | 67 | companion object { 68 | fun goToMainActivity(context: Context) { 69 | Log.d(TAG, "Go to MainActivity") 70 | val intent = 71 | Intent( 72 | context, 73 | MainActivity::class.java 74 | ) 75 | context.startActivity(intent) 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /app/src/main/java/org/unifiedpush/example/activities/ui/AppBarUi.kt: -------------------------------------------------------------------------------- 1 | package org.unifiedpush.example.activities.ui 2 | 3 | import androidx.compose.foundation.layout.Row 4 | import androidx.compose.foundation.layout.Spacer 5 | import androidx.compose.material.icons.Icons 6 | import androidx.compose.material.icons.filled.MoreVert 7 | import androidx.compose.material3.Checkbox 8 | import androidx.compose.material3.DropdownMenu 9 | import androidx.compose.material3.DropdownMenuItem 10 | import androidx.compose.material3.ExperimentalMaterial3Api 11 | import androidx.compose.material3.Icon 12 | import androidx.compose.material3.IconButton 13 | import androidx.compose.material3.MaterialTheme 14 | import androidx.compose.material3.Text 15 | import androidx.compose.material3.TopAppBar 16 | import androidx.compose.material3.TopAppBarDefaults 17 | import androidx.compose.runtime.Composable 18 | import androidx.compose.runtime.getValue 19 | import androidx.compose.runtime.mutableStateOf 20 | import androidx.compose.runtime.remember 21 | import androidx.compose.runtime.setValue 22 | import androidx.compose.ui.Alignment 23 | import androidx.compose.ui.Modifier 24 | import androidx.compose.ui.res.stringResource 25 | import androidx.compose.ui.tooling.preview.Preview 26 | import org.unifiedpush.example.R 27 | import org.unifiedpush.example.activities.AppBarViewModel 28 | 29 | @OptIn(ExperimentalMaterial3Api::class) 30 | @Composable 31 | fun AppBarUi(viewModel: AppBarViewModel) { 32 | var expanded by remember { mutableStateOf(false) } 33 | 34 | TopAppBar( 35 | colors = TopAppBarDefaults 36 | .topAppBarColors( 37 | containerColor = MaterialTheme.colorScheme.primaryContainer, 38 | titleContentColor = MaterialTheme.colorScheme.primary 39 | ), 40 | title = { 41 | Text( 42 | stringResource(R.string.app_name) 43 | ) 44 | }, 45 | actions = { 46 | IconButton( 47 | onClick = { 48 | expanded = !expanded 49 | } 50 | ) { 51 | Icon( 52 | imageVector = Icons.Default.MoreVert, 53 | contentDescription = "Actions" 54 | ) 55 | } 56 | Dropdown( 57 | expanded, 58 | viewModel 59 | ) { 60 | expanded = false 61 | } 62 | } 63 | ) 64 | } 65 | 66 | @Composable 67 | fun Dropdown(expanded: Boolean, viewModel: AppBarViewModel, onDismissRequest: () -> Unit) { 68 | DropdownMenu( 69 | expanded = expanded, 70 | onDismissRequest = { onDismissRequest() } 71 | ) { 72 | DropdownMenuItem( 73 | text = { 74 | Row(verticalAlignment = Alignment.CenterVertically) { 75 | Text("Developer mode") 76 | Spacer(Modifier.weight(1f)) 77 | Checkbox( 78 | viewModel.uiState.devMode, 79 | onCheckedChange = { viewModel.toggleDevMode() } 80 | ) 81 | } 82 | }, 83 | onClick = { viewModel.toggleDevMode() } 84 | ) 85 | if (viewModel.uiState.devMode) { 86 | DropdownMenuItem( 87 | text = { 88 | Row(verticalAlignment = Alignment.CenterVertically) { 89 | Text( 90 | "Error if decryption fails" 91 | ) 92 | Spacer(Modifier.weight(1f)) 93 | Checkbox( 94 | viewModel.uiState.errorIfDecryptionFail, 95 | onCheckedChange = { viewModel.toggleErrorIfDecryptionFail() } 96 | ) 97 | } 98 | }, 99 | onClick = { viewModel.toggleErrorIfDecryptionFail() } 100 | ) 101 | DropdownMenuItem( 102 | text = { 103 | Row(verticalAlignment = Alignment.CenterVertically) { 104 | Text( 105 | "Use VAPID" 106 | ) 107 | Spacer(Modifier.weight(1f)) 108 | Checkbox( 109 | viewModel.uiState.useVapid, 110 | onCheckedChange = { viewModel.toggleUseVapid() } 111 | ) 112 | } 113 | }, 114 | onClick = { viewModel.toggleUseVapid() } 115 | ) 116 | DropdownMenuItem( 117 | text = { 118 | Row(verticalAlignment = Alignment.CenterVertically) { 119 | Text( 120 | "Send cleartext test" 121 | ) 122 | Spacer(Modifier.weight(1f)) 123 | Checkbox( 124 | viewModel.uiState.sendClearTextTests, 125 | onCheckedChange = { viewModel.toggleSendClearTextTests() } 126 | ) 127 | } 128 | }, 129 | onClick = { viewModel.toggleSendClearTextTests() } 130 | ) 131 | DropdownMenuItem( 132 | enabled = viewModel.uiState.useVapid, 133 | text = { 134 | Row(verticalAlignment = Alignment.CenterVertically) { 135 | Text( 136 | "Use wrong VAPID keys" 137 | ) 138 | Spacer(Modifier.weight(1f)) 139 | Checkbox( 140 | viewModel.uiState.useVapid && 141 | viewModel.uiState.useWrongVapidKeys, 142 | onCheckedChange = { viewModel.toggleUseWrongVapidKeys() } 143 | ) 144 | } 145 | }, 146 | onClick = { viewModel.toggleUseWrongVapidKeys() } 147 | ) 148 | DropdownMenuItem( 149 | enabled = !viewModel.uiState.sendClearTextTests, 150 | text = { 151 | Row(verticalAlignment = Alignment.CenterVertically) { 152 | Text( 153 | "Use wrong encryption keys" 154 | ) 155 | Spacer(Modifier.weight(1f)) 156 | Checkbox( 157 | !viewModel.uiState.sendClearTextTests && 158 | viewModel.uiState.useWrongEncryptionKeys, 159 | onCheckedChange = { viewModel.toggleUseWrongEncryptionKeys() } 160 | ) 161 | } 162 | }, 163 | onClick = { viewModel.toggleUseWrongEncryptionKeys() } 164 | ) 165 | DropdownMenuItem( 166 | text = { 167 | Row(verticalAlignment = Alignment.CenterVertically) { 168 | Text( 169 | "Fg service on message" 170 | ) 171 | Spacer(Modifier.weight(1f)) 172 | Checkbox( 173 | viewModel.uiState.startForegroundServiceOnMessage, 174 | onCheckedChange = { viewModel.toggleStartForegroundServiceOnMessage() } 175 | ) 176 | } 177 | }, 178 | onClick = { viewModel.toggleStartForegroundServiceOnMessage() } 179 | ) 180 | } 181 | } 182 | } 183 | 184 | @Preview 185 | @Composable 186 | fun AppBarPreview() { 187 | AppBarUi( 188 | AppBarViewModel( 189 | uiState = AppBarUiState( 190 | devMode = true, 191 | errorIfDecryptionFail = true, 192 | useVapid = true, 193 | sendClearTextTests = true, 194 | useWrongVapidKeys = true, 195 | useWrongEncryptionKeys = true, 196 | startForegroundServiceOnMessage = true 197 | ) 198 | ) 199 | ) 200 | } 201 | -------------------------------------------------------------------------------- /app/src/main/java/org/unifiedpush/example/activities/ui/AppBarUiState.kt: -------------------------------------------------------------------------------- 1 | package org.unifiedpush.example.activities.ui 2 | 3 | import android.content.Context 4 | import org.unifiedpush.example.Store 5 | 6 | data class AppBarUiState( 7 | val devMode: Boolean, 8 | val errorIfDecryptionFail: Boolean, 9 | val useVapid: Boolean, 10 | val sendClearTextTests: Boolean, 11 | val useWrongVapidKeys: Boolean, 12 | val useWrongEncryptionKeys: Boolean, 13 | val startForegroundServiceOnMessage: Boolean 14 | ) { 15 | companion object { 16 | fun from(context: Context): AppBarUiState { 17 | val store = Store(context) 18 | return AppBarUiState( 19 | devMode = store.devMode, 20 | errorIfDecryptionFail = store.devForceEncrypted, 21 | useVapid = store.devUseVapid, 22 | sendClearTextTests = store.devCleartextTest, 23 | useWrongVapidKeys = store.devWrongVapidKeysTest, 24 | useWrongEncryptionKeys = store.devWrongKeysTest, 25 | startForegroundServiceOnMessage = store.devStartForeground 26 | ) 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /app/src/main/java/org/unifiedpush/example/activities/ui/CheckUi.kt: -------------------------------------------------------------------------------- 1 | package org.unifiedpush.example.activities.ui 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.PaddingValues 6 | import androidx.compose.foundation.layout.Row 7 | import androidx.compose.foundation.layout.fillMaxSize 8 | import androidx.compose.foundation.layout.fillMaxWidth 9 | import androidx.compose.foundation.layout.padding 10 | import androidx.compose.foundation.rememberScrollState 11 | import androidx.compose.foundation.text.selection.SelectionContainer 12 | import androidx.compose.foundation.verticalScroll 13 | import androidx.compose.material3.Button 14 | import androidx.compose.material3.HorizontalDivider 15 | import androidx.compose.material3.MaterialTheme 16 | import androidx.compose.material3.Scaffold 17 | import androidx.compose.material3.Text 18 | import androidx.compose.runtime.Composable 19 | import androidx.compose.runtime.getValue 20 | import androidx.compose.runtime.mutableStateOf 21 | import androidx.compose.runtime.remember 22 | import androidx.compose.runtime.setValue 23 | import androidx.compose.ui.Alignment 24 | import androidx.compose.ui.Modifier 25 | import androidx.compose.ui.text.LinkAnnotation 26 | import androidx.compose.ui.text.SpanStyle 27 | import androidx.compose.ui.text.TextLinkStyles 28 | import androidx.compose.ui.text.buildAnnotatedString 29 | import androidx.compose.ui.text.style.TextOverflow 30 | import androidx.compose.ui.text.withLink 31 | import androidx.compose.ui.tooling.preview.Preview 32 | import androidx.compose.ui.unit.dp 33 | import org.unifiedpush.example.Urgency 34 | import org.unifiedpush.example.activities.AppBarViewModel 35 | import org.unifiedpush.example.activities.CheckViewModel 36 | import org.unifiedpush.example.activities.Events 37 | import org.unifiedpush.example.utils.genTestPageUrl 38 | 39 | @Composable 40 | fun CheckUi(appBarViewModel: AppBarViewModel, viewModel: CheckViewModel) { 41 | Scaffold( 42 | topBar = { AppBarUi(appBarViewModel) } 43 | ) { innerPadding -> 44 | CheckUiContent(innerPadding, viewModel) 45 | } 46 | } 47 | 48 | @Composable 49 | fun CheckUiContent(innerPadding: PaddingValues, viewModel: CheckViewModel) { 50 | var showUrgencyDialog by remember { mutableStateOf(false) } 51 | val state = viewModel.uiState 52 | Column( 53 | modifier = Modifier 54 | .fillMaxSize() 55 | .padding( 56 | 16.dp, 57 | innerPadding.calculateTopPadding() + 16.dp, 58 | 16.dp, 59 | innerPadding.calculateBottomPadding() + 16.dp 60 | ) 61 | .verticalScroll(rememberScrollState()), 62 | horizontalAlignment = Alignment.Start, 63 | verticalArrangement = Arrangement.spacedBy(16.dp) 64 | ) { 65 | Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { 66 | Text( 67 | style = MaterialTheme.typography.labelMedium, 68 | text = "Endpoint" 69 | ) 70 | SelectionContainer { Text(state.endpoint) } 71 | } 72 | 73 | Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { 74 | Text( 75 | style = MaterialTheme.typography.labelMedium, 76 | text = "Auth" 77 | ) 78 | SelectionContainer { Text(state.auth) } 79 | } 80 | 81 | Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { 82 | Text( 83 | style = MaterialTheme.typography.labelMedium, 84 | text = "P256dh" 85 | ) 86 | SelectionContainer { Text(state.p256dh) } 87 | } 88 | 89 | if (state.showVapid) { 90 | Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { 91 | Text( 92 | style = MaterialTheme.typography.labelMedium, 93 | text = "VAPID" 94 | ) 95 | SelectionContainer { Text(state.vapid) } 96 | } 97 | } 98 | 99 | Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { 100 | Text( 101 | style = MaterialTheme.typography.labelMedium, 102 | text = "Test page" 103 | ) 104 | Text( 105 | text = buildAnnotatedString { 106 | withLink( 107 | LinkAnnotation.Url( 108 | url = state.testPageUrl, 109 | styles = TextLinkStyles( 110 | style = SpanStyle( 111 | color = MaterialTheme.colorScheme.primary 112 | ) 113 | ) 114 | ) 115 | ) { 116 | append(state.testPageUrl) 117 | } 118 | }, 119 | maxLines = 1, 120 | overflow = TextOverflow.Ellipsis 121 | ) 122 | } 123 | 124 | HorizontalDivider(thickness = 1.dp) 125 | 126 | state.error?.let { 127 | Text(it) 128 | } 129 | 130 | val buttonsList = mutableListOf<@Composable () -> Unit>( 131 | { 132 | Button( 133 | onClick = { 134 | Events.emit(Events.Type.Unregister) 135 | } 136 | ) { 137 | Text("Unregister") 138 | } 139 | }, 140 | { 141 | Button( 142 | onClick = { 143 | Events.emit(Events.Type.SendNotification) 144 | } 145 | ) { 146 | Text("Send notification") 147 | } 148 | } 149 | ) 150 | if (state.devMode) { 151 | buttonsList.addAll( 152 | listOf( 153 | { 154 | Button( 155 | onClick = 156 | { 157 | Events.emit(Events.Type.Reregister) 158 | } 159 | ) { 160 | Text("Reregister") 161 | } 162 | }, 163 | { 164 | Button( 165 | enabled = state.hasForegroundService, 166 | onClick = { 167 | Events.emit(Events.Type.StopForegroundService) 168 | } 169 | ) { 170 | Text("Stop Foreground Service") 171 | } 172 | }, 173 | { 174 | Button( 175 | onClick = { 176 | Events.emit(Events.Type.DeepLink) 177 | } 178 | ) { 179 | Text("Deep link") 180 | } 181 | }, 182 | { 183 | Button( 184 | onClick = { 185 | Events.emit(Events.Type.ChangeDistributor) 186 | } 187 | ) { 188 | Text("Change distributor") 189 | } 190 | }, 191 | { 192 | Button( 193 | enabled = !state.sendCleartext, 194 | onClick = { 195 | showUrgencyDialog = true 196 | } 197 | ) { 198 | Text("Set urgency") 199 | } 200 | }, 201 | { 202 | Button( 203 | onClick = { 204 | Events.emit(Events.Type.UpdateVapidKey) 205 | } 206 | ) { 207 | Text("Update VAPID key") 208 | } 209 | }, 210 | { 211 | Button( 212 | enabled = !state.sendCleartext, 213 | onClick = { 214 | Events.emit(Events.Type.TestTopic) 215 | } 216 | ) { 217 | Text("Test topic") 218 | } 219 | }, 220 | { 221 | Button( 222 | enabled = !state.sendCleartext, 223 | onClick = { 224 | Events.emit(Events.Type.TestTTL) 225 | } 226 | ) { 227 | Text("Test TTL") 228 | } 229 | }, 230 | { 231 | Button( 232 | onClick = { 233 | Events.emit(Events.Type.TestInBackground) 234 | } 235 | ) { 236 | Text("Test in background") 237 | } 238 | } 239 | ) 240 | ) 241 | } 242 | 243 | TwoColumns(buttonsList) 244 | 245 | if (showUrgencyDialog) { 246 | SetUrgencyDialog( 247 | urgency = state.urgency, 248 | onDismissRequest = { showUrgencyDialog = false }, 249 | onConfirmation = { 250 | viewModel.setUrgency(it) 251 | showUrgencyDialog = false 252 | } 253 | ) 254 | } 255 | } 256 | } 257 | 258 | @Composable 259 | fun TwoColumns(list: List<@Composable () -> Unit>) { 260 | list.chunked(2).forEach { item -> 261 | Row( 262 | modifier = Modifier.fillMaxWidth(), 263 | horizontalArrangement = Arrangement.SpaceEvenly 264 | ) { 265 | item[0]() 266 | if (item.size > 1) { 267 | item[1]() 268 | } 269 | } 270 | } 271 | } 272 | 273 | @Preview 274 | @Composable 275 | fun CheckUiPreview() { 276 | val endpoint = "https://my.endpoint.tld" 277 | val p256dh = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + 278 | "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" 279 | val auth = "Auth_random" 280 | val vapid = "vapid t=eyAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + 281 | "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" 282 | val testUrl = genTestPageUrl( 283 | endpoint, 284 | p256dh, 285 | auth, 286 | vapid, 287 | true 288 | ) 289 | CheckUiContent( 290 | PaddingValues(0.dp), 291 | CheckViewModel( 292 | CheckUiState( 293 | error = "error!", 294 | devMode = true, 295 | hasForegroundService = false, 296 | sendCleartext = true, 297 | endpoint = endpoint, 298 | auth = auth, 299 | p256dh = p256dh, 300 | showVapid = true, 301 | vapid = vapid, 302 | testPageUrl = testUrl, 303 | urgency = Urgency.NORMAL 304 | ) 305 | ) 306 | ) 307 | } 308 | -------------------------------------------------------------------------------- /app/src/main/java/org/unifiedpush/example/activities/ui/CheckUiState.kt: -------------------------------------------------------------------------------- 1 | package org.unifiedpush.example.activities.ui 2 | 3 | import android.content.Context 4 | import android.util.Log 5 | import org.unifiedpush.example.ApplicationServer 6 | import org.unifiedpush.example.Store 7 | import org.unifiedpush.example.TestService 8 | import org.unifiedpush.example.Urgency 9 | import org.unifiedpush.example.utils.TAG 10 | import org.unifiedpush.example.utils.genTestPageUrl 11 | import org.unifiedpush.example.utils.vapidImplementedForSdk 12 | 13 | data class CheckUiState( 14 | val error: String? = null, 15 | val devMode: Boolean = false, 16 | val hasForegroundService: Boolean = false, 17 | val sendCleartext: Boolean = false, 18 | val endpoint: String, 19 | val auth: String, 20 | val p256dh: String, 21 | val showVapid: Boolean, 22 | val vapid: String, 23 | val testPageUrl: String, 24 | val urgency: Urgency 25 | ) { 26 | companion object { 27 | fun error(): CheckUiState { 28 | return CheckUiState( 29 | null, 30 | false, 31 | endpoint = "Error", 32 | auth = "Error", 33 | p256dh = "Error", 34 | showVapid = false, 35 | vapid = "Error", 36 | testPageUrl = "Error", 37 | urgency = Urgency.NORMAL 38 | ) 39 | } 40 | 41 | /** 42 | * @return `null` if the application doesn't have any endpoint 43 | */ 44 | fun from(context: Context): CheckUiState? { 45 | val store = Store(context) 46 | 47 | /** 48 | * Auth secret, p256dh and VAPID should never be used when null, 49 | * we set a dummy value. 50 | */ 51 | val endpoint = store.endpoint ?: return null 52 | val p256dh = store.serializedPubKey ?: "Error" 53 | val auth = store.b64authSecret ?: "Error" 54 | val showVapid = vapidImplementedForSdk() && store.devMode && store.devUseVapid 55 | val vapidHeader = if (vapidImplementedForSdk()) { 56 | ApplicationServer(context).getVapidHeader(fakeKeys = (store.devMode && store.devWrongVapidKeysTest)) 57 | } else { 58 | "Error" 59 | } 60 | val testPageUrl = genTestPageUrl( 61 | endpoint, 62 | p256dh, 63 | auth, 64 | vapidHeader, 65 | showVapid 66 | ) 67 | Log.d(TAG, "testPageUrl: $testPageUrl") 68 | return CheckUiState( 69 | devMode = store.devMode, 70 | hasForegroundService = TestService.isStarted(), 71 | sendCleartext = store.devCleartextTest, 72 | endpoint = endpoint, 73 | auth = auth, 74 | p256dh = p256dh, 75 | showVapid = showVapid, 76 | vapid = vapidHeader, 77 | testPageUrl = testPageUrl, 78 | urgency = store.urgency 79 | ) 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /app/src/main/java/org/unifiedpush/example/activities/ui/MainUi.kt: -------------------------------------------------------------------------------- 1 | package org.unifiedpush.example.activities.ui 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.PaddingValues 6 | import androidx.compose.foundation.layout.Spacer 7 | import androidx.compose.foundation.layout.fillMaxSize 8 | import androidx.compose.foundation.layout.height 9 | import androidx.compose.foundation.layout.padding 10 | import androidx.compose.material3.Button 11 | import androidx.compose.material3.Scaffold 12 | import androidx.compose.material3.Text 13 | import androidx.compose.runtime.Composable 14 | import androidx.compose.runtime.getValue 15 | import androidx.compose.runtime.mutableStateOf 16 | import androidx.compose.runtime.remember 17 | import androidx.compose.runtime.setValue 18 | import androidx.compose.ui.Alignment 19 | import androidx.compose.ui.Modifier 20 | import androidx.compose.ui.res.stringResource 21 | import androidx.compose.ui.unit.dp 22 | import org.unifiedpush.example.R 23 | import org.unifiedpush.example.activities.AppBarViewModel 24 | import org.unifiedpush.example.activities.Events 25 | 26 | @Composable 27 | fun MainUi(viewModel: AppBarViewModel) { 28 | Scaffold( 29 | topBar = { AppBarUi(viewModel) } 30 | ) { innerPadding -> 31 | MainUiContent(innerPadding) 32 | } 33 | } 34 | 35 | @Composable 36 | fun MainUiContent(innerPadding: PaddingValues) { 37 | var showPermissionDialog by remember { mutableStateOf(true) } 38 | 39 | if (showPermissionDialog) { 40 | PermissionsUi { 41 | showPermissionDialog = false 42 | } 43 | } 44 | Column( 45 | modifier = Modifier 46 | .fillMaxSize() 47 | .padding( 48 | 16.dp, 49 | innerPadding.calculateTopPadding(), 50 | 16.dp, 51 | innerPadding.calculateBottomPadding() 52 | ), 53 | horizontalAlignment = Alignment.Start, 54 | verticalArrangement = Arrangement.spacedBy(16.dp) 55 | ) { 56 | Spacer(Modifier.height(16.dp)) 57 | Text(stringResource(R.string.about)) 58 | Button( 59 | onClick = { 60 | Events.emit(Events.Type.Register) 61 | } 62 | ) { 63 | Text("Register") 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /app/src/main/java/org/unifiedpush/example/activities/ui/PermissionsUi.kt: -------------------------------------------------------------------------------- 1 | package org.unifiedpush.example.activities.ui 2 | 3 | import android.os.Build 4 | import androidx.compose.material3.AlertDialog 5 | import androidx.compose.material3.Text 6 | import androidx.compose.material3.TextButton 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.res.stringResource 9 | import androidx.compose.ui.tooling.preview.Preview 10 | import com.google.accompanist.permissions.ExperimentalPermissionsApi 11 | import com.google.accompanist.permissions.isGranted 12 | import com.google.accompanist.permissions.rememberPermissionState 13 | 14 | @OptIn(ExperimentalPermissionsApi::class) 15 | @Preview 16 | @Composable 17 | fun PermissionsUi(onDone: () -> Unit = {}) { 18 | if (Build.VERSION.SDK_INT < 33) { 19 | onDone() 20 | return 21 | } 22 | val notificationsPermissionState = 23 | rememberPermissionState( 24 | android.Manifest.permission.POST_NOTIFICATIONS 25 | ) 26 | if (!notificationsPermissionState.status.isGranted) { 27 | AlertDialog( 28 | title = { 29 | Text("Permissions") 30 | }, 31 | text = { 32 | Text("This application requires notifications permission to work.") 33 | }, 34 | onDismissRequest = { 35 | onDone() 36 | }, 37 | confirmButton = { 38 | TextButton( 39 | onClick = { 40 | notificationsPermissionState.launchPermissionRequest() 41 | onDone() 42 | } 43 | ) { 44 | Text(stringResource(android.R.string.ok)) 45 | } 46 | }, 47 | dismissButton = {} 48 | ) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /app/src/main/java/org/unifiedpush/example/activities/ui/SetUrgencyDialog.kt: -------------------------------------------------------------------------------- 1 | package org.unifiedpush.example.activities.ui 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.Row 6 | import androidx.compose.foundation.layout.Spacer 7 | import androidx.compose.material3.AlertDialog 8 | import androidx.compose.material3.Checkbox 9 | import androidx.compose.material3.Text 10 | import androidx.compose.material3.TextButton 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.ui.Alignment 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.res.stringResource 15 | import androidx.compose.ui.tooling.preview.Preview 16 | import androidx.compose.ui.unit.dp 17 | import org.unifiedpush.example.Urgency 18 | 19 | @Preview 20 | @Composable 21 | fun SetUrgencyDialog(urgency: Urgency = Urgency.NORMAL, onDismissRequest: () -> Unit = {}, onConfirmation: (Urgency) -> Unit = {}) { 22 | AlertDialog( 23 | title = { 24 | Text("Urgency") 25 | }, 26 | text = { 27 | Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { 28 | Urgency.entries.forEach { entry -> 29 | Row(verticalAlignment = Alignment.CenterVertically) { 30 | TextButton( 31 | onClick = { onConfirmation(entry) } 32 | ) { 33 | Text( 34 | text = entry.value 35 | ) 36 | } 37 | Spacer(Modifier.weight(1f)) 38 | Checkbox( 39 | entry == urgency, 40 | onCheckedChange = { onConfirmation(entry) } 41 | ) 42 | } 43 | } 44 | } 45 | }, 46 | onDismissRequest = { 47 | onDismissRequest() 48 | }, 49 | confirmButton = { }, 50 | dismissButton = { 51 | TextButton( 52 | onClick = { 53 | onDismissRequest() 54 | } 55 | ) { 56 | Text(stringResource(android.R.string.cancel)) 57 | } 58 | } 59 | ) 60 | } 61 | -------------------------------------------------------------------------------- /app/src/main/java/org/unifiedpush/example/activities/ui/theme/Color.kt: -------------------------------------------------------------------------------- 1 | package org.unifiedpush.example.activities.ui.theme 2 | 3 | import androidx.compose.ui.graphics.Color 4 | 5 | val primaryLight = Color(0xFF6A538C) 6 | val onPrimaryLight = Color(0xFFFFFFFF) 7 | val primaryContainerLight = Color(0xFFEDDCFF) 8 | val onPrimaryContainerLight = Color(0xFF250E44) 9 | val secondaryLight = Color(0xFF645A70) 10 | val onSecondaryLight = Color(0xFFFFFFFF) 11 | val secondaryContainerLight = Color(0xFFEBDDF7) 12 | val onSecondaryContainerLight = Color(0xFF20182A) 13 | val tertiaryLight = Color(0xFF7F525B) 14 | val onTertiaryLight = Color(0xFFFFFFFF) 15 | val tertiaryContainerLight = Color(0xFFFFD9DF) 16 | val onTertiaryContainerLight = Color(0xFF321019) 17 | val errorLight = Color(0xFFBA1A1A) 18 | val onErrorLight = Color(0xFFFFFFFF) 19 | val errorContainerLight = Color(0xFFFFDAD6) 20 | val onErrorContainerLight = Color(0xFF410002) 21 | val backgroundLight = Color(0xFFFEF7FF) 22 | val onBackgroundLight = Color(0xFF1D1A20) 23 | val surfaceLight = Color(0xFFFEF7FF) 24 | val onSurfaceLight = Color(0xFF1D1A20) 25 | val surfaceVariantLight = Color(0xFFE8E0EB) 26 | val onSurfaceVariantLight = Color(0xFF4A454E) 27 | val outlineLight = Color(0xFF7B757F) 28 | val outlineVariantLight = Color(0xFFCBC4CF) 29 | val scrimLight = Color(0xFF000000) 30 | val inverseSurfaceLight = Color(0xFF322F35) 31 | val inverseOnSurfaceLight = Color(0xFFF6EEF7) 32 | val inversePrimaryLight = Color(0xFFD6BBFB) 33 | val surfaceDimLight = Color(0xFFDFD8E0) 34 | val surfaceBrightLight = Color(0xFFFEF7FF) 35 | val surfaceContainerLowestLight = Color(0xFFFFFFFF) 36 | val surfaceContainerLowLight = Color(0xFFF9F1F9) 37 | val surfaceContainerLight = Color(0xFFF3ECF4) 38 | val surfaceContainerHighLight = Color(0xFFEDE6EE) 39 | val surfaceContainerHighestLight = Color(0xFFE7E0E8) 40 | 41 | val primaryLightMediumContrast = Color(0xFF4E386F) 42 | val onPrimaryLightMediumContrast = Color(0xFFFFFFFF) 43 | val primaryContainerLightMediumContrast = Color(0xFF816AA4) 44 | val onPrimaryContainerLightMediumContrast = Color(0xFFFFFFFF) 45 | val secondaryLightMediumContrast = Color(0xFF483F53) 46 | val onSecondaryLightMediumContrast = Color(0xFFFFFFFF) 47 | val secondaryContainerLightMediumContrast = Color(0xFF7B7087) 48 | val onSecondaryContainerLightMediumContrast = Color(0xFFFFFFFF) 49 | val tertiaryLightMediumContrast = Color(0xFF603740) 50 | val onTertiaryLightMediumContrast = Color(0xFFFFFFFF) 51 | val tertiaryContainerLightMediumContrast = Color(0xFF986771) 52 | val onTertiaryContainerLightMediumContrast = Color(0xFFFFFFFF) 53 | val errorLightMediumContrast = Color(0xFF8C0009) 54 | val onErrorLightMediumContrast = Color(0xFFFFFFFF) 55 | val errorContainerLightMediumContrast = Color(0xFFDA342E) 56 | val onErrorContainerLightMediumContrast = Color(0xFFFFFFFF) 57 | val backgroundLightMediumContrast = Color(0xFFFEF7FF) 58 | val onBackgroundLightMediumContrast = Color(0xFF1D1A20) 59 | val surfaceLightMediumContrast = Color(0xFFFEF7FF) 60 | val onSurfaceLightMediumContrast = Color(0xFF1D1A20) 61 | val surfaceVariantLightMediumContrast = Color(0xFFE8E0EB) 62 | val onSurfaceVariantLightMediumContrast = Color(0xFF46414A) 63 | val outlineLightMediumContrast = Color(0xFF625D67) 64 | val outlineVariantLightMediumContrast = Color(0xFF7E7982) 65 | val scrimLightMediumContrast = Color(0xFF000000) 66 | val inverseSurfaceLightMediumContrast = Color(0xFF322F35) 67 | val inverseOnSurfaceLightMediumContrast = Color(0xFFF6EEF7) 68 | val inversePrimaryLightMediumContrast = Color(0xFFD6BBFB) 69 | val surfaceDimLightMediumContrast = Color(0xFFDFD8E0) 70 | val surfaceBrightLightMediumContrast = Color(0xFFFEF7FF) 71 | val surfaceContainerLowestLightMediumContrast = Color(0xFFFFFFFF) 72 | val surfaceContainerLowLightMediumContrast = Color(0xFFF9F1F9) 73 | val surfaceContainerLightMediumContrast = Color(0xFFF3ECF4) 74 | val surfaceContainerHighLightMediumContrast = Color(0xFFEDE6EE) 75 | val surfaceContainerHighestLightMediumContrast = Color(0xFFE7E0E8) 76 | 77 | val primaryLightHighContrast = Color(0xFF2C164B) 78 | val onPrimaryLightHighContrast = Color(0xFFFFFFFF) 79 | val primaryContainerLightHighContrast = Color(0xFF4E386F) 80 | val onPrimaryContainerLightHighContrast = Color(0xFFFFFFFF) 81 | val secondaryLightHighContrast = Color(0xFF261F31) 82 | val onSecondaryLightHighContrast = Color(0xFFFFFFFF) 83 | val secondaryContainerLightHighContrast = Color(0xFF483F53) 84 | val onSecondaryContainerLightHighContrast = Color(0xFFFFFFFF) 85 | val tertiaryLightHighContrast = Color(0xFF3A1720) 86 | val onTertiaryLightHighContrast = Color(0xFFFFFFFF) 87 | val tertiaryContainerLightHighContrast = Color(0xFF603740) 88 | val onTertiaryContainerLightHighContrast = Color(0xFFFFFFFF) 89 | val errorLightHighContrast = Color(0xFF4E0002) 90 | val onErrorLightHighContrast = Color(0xFFFFFFFF) 91 | val errorContainerLightHighContrast = Color(0xFF8C0009) 92 | val onErrorContainerLightHighContrast = Color(0xFFFFFFFF) 93 | val backgroundLightHighContrast = Color(0xFFFEF7FF) 94 | val onBackgroundLightHighContrast = Color(0xFF1D1A20) 95 | val surfaceLightHighContrast = Color(0xFFFEF7FF) 96 | val onSurfaceLightHighContrast = Color(0xFF000000) 97 | val surfaceVariantLightHighContrast = Color(0xFFE8E0EB) 98 | val onSurfaceVariantLightHighContrast = Color(0xFF26222A) 99 | val outlineLightHighContrast = Color(0xFF46414A) 100 | val outlineVariantLightHighContrast = Color(0xFF46414A) 101 | val scrimLightHighContrast = Color(0xFF000000) 102 | val inverseSurfaceLightHighContrast = Color(0xFF322F35) 103 | val inverseOnSurfaceLightHighContrast = Color(0xFFFFFFFF) 104 | val inversePrimaryLightHighContrast = Color(0xFFF4E7FF) 105 | val surfaceDimLightHighContrast = Color(0xFFDFD8E0) 106 | val surfaceBrightLightHighContrast = Color(0xFFFEF7FF) 107 | val surfaceContainerLowestLightHighContrast = Color(0xFFFFFFFF) 108 | val surfaceContainerLowLightHighContrast = Color(0xFFF9F1F9) 109 | val surfaceContainerLightHighContrast = Color(0xFFF3ECF4) 110 | val surfaceContainerHighLightHighContrast = Color(0xFFEDE6EE) 111 | val surfaceContainerHighestLightHighContrast = Color(0xFFE7E0E8) 112 | 113 | val primaryDark = Color(0xFFD6BBFB) 114 | val onPrimaryDark = Color(0xFF3B255B) 115 | val primaryContainerDark = Color(0xFF523C73) 116 | val onPrimaryContainerDark = Color(0xFFEDDCFF) 117 | val secondaryDark = Color(0xFFCEC2DA) 118 | val onSecondaryDark = Color(0xFF352D40) 119 | val secondaryContainerDark = Color(0xFF4C4357) 120 | val onSecondaryContainerDark = Color(0xFFEBDDF7) 121 | val tertiaryDark = Color(0xFFF1B7C2) 122 | val onTertiaryDark = Color(0xFF4B252E) 123 | val tertiaryContainerDark = Color(0xFF653B44) 124 | val onTertiaryContainerDark = Color(0xFFFFD9DF) 125 | val errorDark = Color(0xFFFFB4AB) 126 | val onErrorDark = Color(0xFF690005) 127 | val errorContainerDark = Color(0xFF93000A) 128 | val onErrorContainerDark = Color(0xFFFFDAD6) 129 | val backgroundDark = Color(0xFF151218) 130 | val onBackgroundDark = Color(0xFFE7E0E8) 131 | val surfaceDark = Color(0xFF151218) 132 | val onSurfaceDark = Color(0xFFE7E0E8) 133 | val surfaceVariantDark = Color(0xFF4A454E) 134 | val onSurfaceVariantDark = Color(0xFFCBC4CF) 135 | val outlineDark = Color(0xFF958E99) 136 | val outlineVariantDark = Color(0xFF4A454E) 137 | val scrimDark = Color(0xFF000000) 138 | val inverseSurfaceDark = Color(0xFFE7E0E8) 139 | val inverseOnSurfaceDark = Color(0xFF322F35) 140 | val inversePrimaryDark = Color(0xFF6A538C) 141 | val surfaceDimDark = Color(0xFF151218) 142 | val surfaceBrightDark = Color(0xFF3B383E) 143 | val surfaceContainerLowestDark = Color(0xFF100D12) 144 | val surfaceContainerLowDark = Color(0xFF1D1A20) 145 | val surfaceContainerDark = Color(0xFF211E24) 146 | val surfaceContainerHighDark = Color(0xFF2C292F) 147 | val surfaceContainerHighestDark = Color(0xFF37333A) 148 | 149 | val primaryDarkMediumContrast = Color(0xFFDABFFF) 150 | val onPrimaryDarkMediumContrast = Color(0xFF1F073F) 151 | val primaryContainerDarkMediumContrast = Color(0xFF9E85C2) 152 | val onPrimaryContainerDarkMediumContrast = Color(0xFF000000) 153 | val secondaryDarkMediumContrast = Color(0xFFD3C6DF) 154 | val onSecondaryDarkMediumContrast = Color(0xFF1A1325) 155 | val secondaryContainerDarkMediumContrast = Color(0xFF978CA3) 156 | val onSecondaryContainerDarkMediumContrast = Color(0xFF000000) 157 | val tertiaryDarkMediumContrast = Color(0xFFF6BBC6) 158 | val onTertiaryDarkMediumContrast = Color(0xFF2B0B14) 159 | val tertiaryContainerDarkMediumContrast = Color(0xFFB7838D) 160 | val onTertiaryContainerDarkMediumContrast = Color(0xFF000000) 161 | val errorDarkMediumContrast = Color(0xFFFFBAB1) 162 | val onErrorDarkMediumContrast = Color(0xFF370001) 163 | val errorContainerDarkMediumContrast = Color(0xFFFF5449) 164 | val onErrorContainerDarkMediumContrast = Color(0xFF000000) 165 | val backgroundDarkMediumContrast = Color(0xFF151218) 166 | val onBackgroundDarkMediumContrast = Color(0xFFE7E0E8) 167 | val surfaceDarkMediumContrast = Color(0xFF151218) 168 | val onSurfaceDarkMediumContrast = Color(0xFFFFF9FD) 169 | val surfaceVariantDarkMediumContrast = Color(0xFF4A454E) 170 | val onSurfaceVariantDarkMediumContrast = Color(0xFFD0C8D3) 171 | val outlineDarkMediumContrast = Color(0xFFA7A0AB) 172 | val outlineVariantDarkMediumContrast = Color(0xFF87818B) 173 | val scrimDarkMediumContrast = Color(0xFF000000) 174 | val inverseSurfaceDarkMediumContrast = Color(0xFFE7E0E8) 175 | val inverseOnSurfaceDarkMediumContrast = Color(0xFF2C292F) 176 | val inversePrimaryDarkMediumContrast = Color(0xFF533D74) 177 | val surfaceDimDarkMediumContrast = Color(0xFF151218) 178 | val surfaceBrightDarkMediumContrast = Color(0xFF3B383E) 179 | val surfaceContainerLowestDarkMediumContrast = Color(0xFF100D12) 180 | val surfaceContainerLowDarkMediumContrast = Color(0xFF1D1A20) 181 | val surfaceContainerDarkMediumContrast = Color(0xFF211E24) 182 | val surfaceContainerHighDarkMediumContrast = Color(0xFF2C292F) 183 | val surfaceContainerHighestDarkMediumContrast = Color(0xFF37333A) 184 | 185 | val primaryDarkHighContrast = Color(0xFFFFF9FD) 186 | val onPrimaryDarkHighContrast = Color(0xFF000000) 187 | val primaryContainerDarkHighContrast = Color(0xFFDABFFF) 188 | val onPrimaryContainerDarkHighContrast = Color(0xFF000000) 189 | val secondaryDarkHighContrast = Color(0xFFFFF9FD) 190 | val onSecondaryDarkHighContrast = Color(0xFF000000) 191 | val secondaryContainerDarkHighContrast = Color(0xFFD3C6DF) 192 | val onSecondaryContainerDarkHighContrast = Color(0xFF000000) 193 | val tertiaryDarkHighContrast = Color(0xFFFFF9F9) 194 | val onTertiaryDarkHighContrast = Color(0xFF000000) 195 | val tertiaryContainerDarkHighContrast = Color(0xFFF6BBC6) 196 | val onTertiaryContainerDarkHighContrast = Color(0xFF000000) 197 | val errorDarkHighContrast = Color(0xFFFFF9F9) 198 | val onErrorDarkHighContrast = Color(0xFF000000) 199 | val errorContainerDarkHighContrast = Color(0xFFFFBAB1) 200 | val onErrorContainerDarkHighContrast = Color(0xFF000000) 201 | val backgroundDarkHighContrast = Color(0xFF151218) 202 | val onBackgroundDarkHighContrast = Color(0xFFE7E0E8) 203 | val surfaceDarkHighContrast = Color(0xFF151218) 204 | val onSurfaceDarkHighContrast = Color(0xFFFFFFFF) 205 | val surfaceVariantDarkHighContrast = Color(0xFF4A454E) 206 | val onSurfaceVariantDarkHighContrast = Color(0xFFFFF9FD) 207 | val outlineDarkHighContrast = Color(0xFFD0C8D3) 208 | val outlineVariantDarkHighContrast = Color(0xFFD0C8D3) 209 | val scrimDarkHighContrast = Color(0xFF000000) 210 | val inverseSurfaceDarkHighContrast = Color(0xFFE7E0E8) 211 | val inverseOnSurfaceDarkHighContrast = Color(0xFF000000) 212 | val inversePrimaryDarkHighContrast = Color(0xFF341E54) 213 | val surfaceDimDarkHighContrast = Color(0xFF151218) 214 | val surfaceBrightDarkHighContrast = Color(0xFF3B383E) 215 | val surfaceContainerLowestDarkHighContrast = Color(0xFF100D12) 216 | val surfaceContainerLowDarkHighContrast = Color(0xFF1D1A20) 217 | val surfaceContainerDarkHighContrast = Color(0xFF211E24) 218 | val surfaceContainerHighDarkHighContrast = Color(0xFF2C292F) 219 | val surfaceContainerHighestDarkHighContrast = Color(0xFF37333A) 220 | -------------------------------------------------------------------------------- /app/src/main/java/org/unifiedpush/example/activities/ui/theme/Theme.kt: -------------------------------------------------------------------------------- 1 | package org.unifiedpush.example.activities.ui.theme 2 | 3 | import android.os.Build 4 | import androidx.compose.foundation.isSystemInDarkTheme 5 | import androidx.compose.material3.MaterialTheme 6 | import androidx.compose.material3.darkColorScheme 7 | import androidx.compose.material3.dynamicDarkColorScheme 8 | import androidx.compose.material3.dynamicLightColorScheme 9 | import androidx.compose.material3.lightColorScheme 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.runtime.Immutable 12 | import androidx.compose.ui.graphics.Color 13 | import androidx.compose.ui.platform.LocalContext 14 | 15 | private val lightScheme = lightColorScheme( 16 | primary = primaryLight, 17 | onPrimary = onPrimaryLight, 18 | primaryContainer = primaryContainerLight, 19 | onPrimaryContainer = onPrimaryContainerLight, 20 | secondary = secondaryLight, 21 | onSecondary = onSecondaryLight, 22 | secondaryContainer = secondaryContainerLight, 23 | onSecondaryContainer = onSecondaryContainerLight, 24 | tertiary = tertiaryLight, 25 | onTertiary = onTertiaryLight, 26 | tertiaryContainer = tertiaryContainerLight, 27 | onTertiaryContainer = onTertiaryContainerLight, 28 | error = errorLight, 29 | onError = onErrorLight, 30 | errorContainer = errorContainerLight, 31 | onErrorContainer = onErrorContainerLight, 32 | background = backgroundLight, 33 | onBackground = onBackgroundLight, 34 | surface = surfaceLight, 35 | onSurface = onSurfaceLight, 36 | surfaceVariant = surfaceVariantLight, 37 | onSurfaceVariant = onSurfaceVariantLight, 38 | outline = outlineLight, 39 | outlineVariant = outlineVariantLight, 40 | scrim = scrimLight, 41 | inverseSurface = inverseSurfaceLight, 42 | inverseOnSurface = inverseOnSurfaceLight, 43 | inversePrimary = inversePrimaryLight, 44 | surfaceDim = surfaceDimLight, 45 | surfaceBright = surfaceBrightLight, 46 | surfaceContainerLowest = surfaceContainerLowestLight, 47 | surfaceContainerLow = surfaceContainerLowLight, 48 | surfaceContainer = surfaceContainerLight, 49 | surfaceContainerHigh = surfaceContainerHighLight, 50 | surfaceContainerHighest = surfaceContainerHighestLight 51 | ) 52 | 53 | private val darkScheme = darkColorScheme( 54 | primary = primaryDark, 55 | onPrimary = onPrimaryDark, 56 | primaryContainer = primaryContainerDark, 57 | onPrimaryContainer = onPrimaryContainerDark, 58 | secondary = secondaryDark, 59 | onSecondary = onSecondaryDark, 60 | secondaryContainer = secondaryContainerDark, 61 | onSecondaryContainer = onSecondaryContainerDark, 62 | tertiary = tertiaryDark, 63 | onTertiary = onTertiaryDark, 64 | tertiaryContainer = tertiaryContainerDark, 65 | onTertiaryContainer = onTertiaryContainerDark, 66 | error = errorDark, 67 | onError = onErrorDark, 68 | errorContainer = errorContainerDark, 69 | onErrorContainer = onErrorContainerDark, 70 | background = backgroundDark, 71 | onBackground = onBackgroundDark, 72 | surface = surfaceDark, 73 | onSurface = onSurfaceDark, 74 | surfaceVariant = surfaceVariantDark, 75 | onSurfaceVariant = onSurfaceVariantDark, 76 | outline = outlineDark, 77 | outlineVariant = outlineVariantDark, 78 | scrim = scrimDark, 79 | inverseSurface = inverseSurfaceDark, 80 | inverseOnSurface = inverseOnSurfaceDark, 81 | inversePrimary = inversePrimaryDark, 82 | surfaceDim = surfaceDimDark, 83 | surfaceBright = surfaceBrightDark, 84 | surfaceContainerLowest = surfaceContainerLowestDark, 85 | surfaceContainerLow = surfaceContainerLowDark, 86 | surfaceContainer = surfaceContainerDark, 87 | surfaceContainerHigh = surfaceContainerHighDark, 88 | surfaceContainerHighest = surfaceContainerHighestDark 89 | ) 90 | 91 | private val mediumContrastLightColorScheme = lightColorScheme( 92 | primary = primaryLightMediumContrast, 93 | onPrimary = onPrimaryLightMediumContrast, 94 | primaryContainer = primaryContainerLightMediumContrast, 95 | onPrimaryContainer = onPrimaryContainerLightMediumContrast, 96 | secondary = secondaryLightMediumContrast, 97 | onSecondary = onSecondaryLightMediumContrast, 98 | secondaryContainer = secondaryContainerLightMediumContrast, 99 | onSecondaryContainer = onSecondaryContainerLightMediumContrast, 100 | tertiary = tertiaryLightMediumContrast, 101 | onTertiary = onTertiaryLightMediumContrast, 102 | tertiaryContainer = tertiaryContainerLightMediumContrast, 103 | onTertiaryContainer = onTertiaryContainerLightMediumContrast, 104 | error = errorLightMediumContrast, 105 | onError = onErrorLightMediumContrast, 106 | errorContainer = errorContainerLightMediumContrast, 107 | onErrorContainer = onErrorContainerLightMediumContrast, 108 | background = backgroundLightMediumContrast, 109 | onBackground = onBackgroundLightMediumContrast, 110 | surface = surfaceLightMediumContrast, 111 | onSurface = onSurfaceLightMediumContrast, 112 | surfaceVariant = surfaceVariantLightMediumContrast, 113 | onSurfaceVariant = onSurfaceVariantLightMediumContrast, 114 | outline = outlineLightMediumContrast, 115 | outlineVariant = outlineVariantLightMediumContrast, 116 | scrim = scrimLightMediumContrast, 117 | inverseSurface = inverseSurfaceLightMediumContrast, 118 | inverseOnSurface = inverseOnSurfaceLightMediumContrast, 119 | inversePrimary = inversePrimaryLightMediumContrast, 120 | surfaceDim = surfaceDimLightMediumContrast, 121 | surfaceBright = surfaceBrightLightMediumContrast, 122 | surfaceContainerLowest = surfaceContainerLowestLightMediumContrast, 123 | surfaceContainerLow = surfaceContainerLowLightMediumContrast, 124 | surfaceContainer = surfaceContainerLightMediumContrast, 125 | surfaceContainerHigh = surfaceContainerHighLightMediumContrast, 126 | surfaceContainerHighest = surfaceContainerHighestLightMediumContrast 127 | ) 128 | 129 | private val highContrastLightColorScheme = lightColorScheme( 130 | primary = primaryLightHighContrast, 131 | onPrimary = onPrimaryLightHighContrast, 132 | primaryContainer = primaryContainerLightHighContrast, 133 | onPrimaryContainer = onPrimaryContainerLightHighContrast, 134 | secondary = secondaryLightHighContrast, 135 | onSecondary = onSecondaryLightHighContrast, 136 | secondaryContainer = secondaryContainerLightHighContrast, 137 | onSecondaryContainer = onSecondaryContainerLightHighContrast, 138 | tertiary = tertiaryLightHighContrast, 139 | onTertiary = onTertiaryLightHighContrast, 140 | tertiaryContainer = tertiaryContainerLightHighContrast, 141 | onTertiaryContainer = onTertiaryContainerLightHighContrast, 142 | error = errorLightHighContrast, 143 | onError = onErrorLightHighContrast, 144 | errorContainer = errorContainerLightHighContrast, 145 | onErrorContainer = onErrorContainerLightHighContrast, 146 | background = backgroundLightHighContrast, 147 | onBackground = onBackgroundLightHighContrast, 148 | surface = surfaceLightHighContrast, 149 | onSurface = onSurfaceLightHighContrast, 150 | surfaceVariant = surfaceVariantLightHighContrast, 151 | onSurfaceVariant = onSurfaceVariantLightHighContrast, 152 | outline = outlineLightHighContrast, 153 | outlineVariant = outlineVariantLightHighContrast, 154 | scrim = scrimLightHighContrast, 155 | inverseSurface = inverseSurfaceLightHighContrast, 156 | inverseOnSurface = inverseOnSurfaceLightHighContrast, 157 | inversePrimary = inversePrimaryLightHighContrast, 158 | surfaceDim = surfaceDimLightHighContrast, 159 | surfaceBright = surfaceBrightLightHighContrast, 160 | surfaceContainerLowest = surfaceContainerLowestLightHighContrast, 161 | surfaceContainerLow = surfaceContainerLowLightHighContrast, 162 | surfaceContainer = surfaceContainerLightHighContrast, 163 | surfaceContainerHigh = surfaceContainerHighLightHighContrast, 164 | surfaceContainerHighest = surfaceContainerHighestLightHighContrast 165 | ) 166 | 167 | private val mediumContrastDarkColorScheme = darkColorScheme( 168 | primary = primaryDarkMediumContrast, 169 | onPrimary = onPrimaryDarkMediumContrast, 170 | primaryContainer = primaryContainerDarkMediumContrast, 171 | onPrimaryContainer = onPrimaryContainerDarkMediumContrast, 172 | secondary = secondaryDarkMediumContrast, 173 | onSecondary = onSecondaryDarkMediumContrast, 174 | secondaryContainer = secondaryContainerDarkMediumContrast, 175 | onSecondaryContainer = onSecondaryContainerDarkMediumContrast, 176 | tertiary = tertiaryDarkMediumContrast, 177 | onTertiary = onTertiaryDarkMediumContrast, 178 | tertiaryContainer = tertiaryContainerDarkMediumContrast, 179 | onTertiaryContainer = onTertiaryContainerDarkMediumContrast, 180 | error = errorDarkMediumContrast, 181 | onError = onErrorDarkMediumContrast, 182 | errorContainer = errorContainerDarkMediumContrast, 183 | onErrorContainer = onErrorContainerDarkMediumContrast, 184 | background = backgroundDarkMediumContrast, 185 | onBackground = onBackgroundDarkMediumContrast, 186 | surface = surfaceDarkMediumContrast, 187 | onSurface = onSurfaceDarkMediumContrast, 188 | surfaceVariant = surfaceVariantDarkMediumContrast, 189 | onSurfaceVariant = onSurfaceVariantDarkMediumContrast, 190 | outline = outlineDarkMediumContrast, 191 | outlineVariant = outlineVariantDarkMediumContrast, 192 | scrim = scrimDarkMediumContrast, 193 | inverseSurface = inverseSurfaceDarkMediumContrast, 194 | inverseOnSurface = inverseOnSurfaceDarkMediumContrast, 195 | inversePrimary = inversePrimaryDarkMediumContrast, 196 | surfaceDim = surfaceDimDarkMediumContrast, 197 | surfaceBright = surfaceBrightDarkMediumContrast, 198 | surfaceContainerLowest = surfaceContainerLowestDarkMediumContrast, 199 | surfaceContainerLow = surfaceContainerLowDarkMediumContrast, 200 | surfaceContainer = surfaceContainerDarkMediumContrast, 201 | surfaceContainerHigh = surfaceContainerHighDarkMediumContrast, 202 | surfaceContainerHighest = surfaceContainerHighestDarkMediumContrast 203 | ) 204 | 205 | private val highContrastDarkColorScheme = darkColorScheme( 206 | primary = primaryDarkHighContrast, 207 | onPrimary = onPrimaryDarkHighContrast, 208 | primaryContainer = primaryContainerDarkHighContrast, 209 | onPrimaryContainer = onPrimaryContainerDarkHighContrast, 210 | secondary = secondaryDarkHighContrast, 211 | onSecondary = onSecondaryDarkHighContrast, 212 | secondaryContainer = secondaryContainerDarkHighContrast, 213 | onSecondaryContainer = onSecondaryContainerDarkHighContrast, 214 | tertiary = tertiaryDarkHighContrast, 215 | onTertiary = onTertiaryDarkHighContrast, 216 | tertiaryContainer = tertiaryContainerDarkHighContrast, 217 | onTertiaryContainer = onTertiaryContainerDarkHighContrast, 218 | error = errorDarkHighContrast, 219 | onError = onErrorDarkHighContrast, 220 | errorContainer = errorContainerDarkHighContrast, 221 | onErrorContainer = onErrorContainerDarkHighContrast, 222 | background = backgroundDarkHighContrast, 223 | onBackground = onBackgroundDarkHighContrast, 224 | surface = surfaceDarkHighContrast, 225 | onSurface = onSurfaceDarkHighContrast, 226 | surfaceVariant = surfaceVariantDarkHighContrast, 227 | onSurfaceVariant = onSurfaceVariantDarkHighContrast, 228 | outline = outlineDarkHighContrast, 229 | outlineVariant = outlineVariantDarkHighContrast, 230 | scrim = scrimDarkHighContrast, 231 | inverseSurface = inverseSurfaceDarkHighContrast, 232 | inverseOnSurface = inverseOnSurfaceDarkHighContrast, 233 | inversePrimary = inversePrimaryDarkHighContrast, 234 | surfaceDim = surfaceDimDarkHighContrast, 235 | surfaceBright = surfaceBrightDarkHighContrast, 236 | surfaceContainerLowest = surfaceContainerLowestDarkHighContrast, 237 | surfaceContainerLow = surfaceContainerLowDarkHighContrast, 238 | surfaceContainer = surfaceContainerDarkHighContrast, 239 | surfaceContainerHigh = surfaceContainerHighDarkHighContrast, 240 | surfaceContainerHighest = surfaceContainerHighestDarkHighContrast 241 | ) 242 | 243 | @Immutable 244 | data class ColorFamily( 245 | val color: Color, 246 | val onColor: Color, 247 | val colorContainer: Color, 248 | val onColorContainer: Color 249 | ) 250 | 251 | val unspecified_scheme = ColorFamily( 252 | Color.Unspecified, 253 | Color.Unspecified, 254 | Color.Unspecified, 255 | Color.Unspecified 256 | ) 257 | 258 | @Composable 259 | fun AppTheme( 260 | darkTheme: Boolean = isSystemInDarkTheme(), 261 | // Dynamic color is available on Android 12+ 262 | dynamicColor: Boolean = false, 263 | content: 264 | @Composable() 265 | () -> Unit 266 | ) { 267 | val colorScheme = when { 268 | dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { 269 | val context = LocalContext.current 270 | if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) 271 | } 272 | 273 | darkTheme -> darkScheme 274 | else -> lightScheme 275 | } 276 | 277 | MaterialTheme( 278 | colorScheme = colorScheme, 279 | typography = AppTypography, 280 | content = content 281 | ) 282 | } 283 | -------------------------------------------------------------------------------- /app/src/main/java/org/unifiedpush/example/activities/ui/theme/Type.kt: -------------------------------------------------------------------------------- 1 | package org.unifiedpush.example.activities.ui.theme 2 | 3 | import androidx.compose.material3.Typography 4 | 5 | val AppTypography = Typography() 6 | -------------------------------------------------------------------------------- /app/src/main/java/org/unifiedpush/example/utils/DelayedRequestWorker.kt: -------------------------------------------------------------------------------- 1 | package org.unifiedpush.example.utils 2 | 3 | import android.content.Context 4 | import android.util.Log 5 | import androidx.work.ExistingWorkPolicy 6 | import androidx.work.OneTimeWorkRequest 7 | import androidx.work.WorkManager 8 | import androidx.work.Worker 9 | import androidx.work.WorkerParameters 10 | import java.util.concurrent.TimeUnit 11 | import org.unifiedpush.example.ApplicationServer 12 | 13 | class DelayedRequestWorker(context: Context, workerParams: WorkerParameters) : 14 | Worker( 15 | context, 16 | workerParams 17 | ) { 18 | override fun doWork(): Result { 19 | val tag = DelayedRequestWorker::class.java.simpleName 20 | ApplicationServer(applicationContext).sendNotification { e -> 21 | e?.let { 22 | Log.d(tag, "An error occurred. $e") 23 | } ?: Log.d(tag, "Notification sent.") 24 | } 25 | return Result.success() 26 | } 27 | 28 | companion object { 29 | fun enqueue(context: Context, delayMs: Long) { 30 | val worker = 31 | OneTimeWorkRequest.Builder(DelayedRequestWorker::class.java).apply { 32 | setInitialDelay(delayMs, TimeUnit.MILLISECONDS) 33 | } 34 | WorkManager.getInstance(context) 35 | .beginUniqueWork("BackgroundTest", ExistingWorkPolicy.REPLACE, worker.build()) 36 | .enqueue() 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /app/src/main/java/org/unifiedpush/example/utils/MessageParser.kt: -------------------------------------------------------------------------------- 1 | package org.unifiedpush.example.utils 2 | 3 | import java.net.URLDecoder 4 | 5 | private const val COULD_NOT_DECRYPT = "Could not decrypt content." 6 | 7 | /** 8 | * Decode message as a `application/x-www-form-urlencoded` message. 9 | * 10 | * If the message doesn't contain "title=" and "message=", the full body is considered as the message. 11 | * If the message contains unknown characters, the message is [COULD_NOT_DECRYPT]. 12 | * 13 | * @return a map of key => value. 14 | * "title=myTitle&message=myContent" will return a Map {"title"=>"myTitle, "message"=>"myContent"} 15 | */ 16 | internal fun decodeMessage(message: String): Map { 17 | val params = 18 | try { 19 | val dict = message.split("&") 20 | dict.associate { 21 | try { 22 | URLDecoder.decode(it.split("=")[0], "UTF-8") to 23 | URLDecoder.decode(it.split("=")[1], "UTF-8") 24 | } catch (e: Exception) { 25 | "" to "" 26 | } 27 | } 28 | } catch (e: Exception) { 29 | notDecodedMap(message) 30 | } 31 | if (params.keys.contains("message") && params.keys.contains("title")) { 32 | return params 33 | } 34 | return notDecodedMap(message) 35 | } 36 | 37 | private fun notDecodedMap(message: String): Map { 38 | return if (message.all { it.isDefined() && !it.isISOControl() }) { 39 | mapOf( 40 | "message" to message 41 | ) 42 | } else { 43 | mapOf( 44 | "message" to COULD_NOT_DECRYPT 45 | ) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /app/src/main/java/org/unifiedpush/example/utils/Notifier.kt: -------------------------------------------------------------------------------- 1 | package org.unifiedpush.example.utils 2 | 3 | import android.app.Notification 4 | import android.app.NotificationChannel 5 | import android.app.NotificationManager 6 | import android.content.Context 7 | import android.os.Build 8 | import androidx.annotation.RequiresApi 9 | import java.util.concurrent.ThreadLocalRandom 10 | import org.unifiedpush.example.R 11 | 12 | class Notifier(var context: Context) { 13 | private val channelId = context.packageName 14 | private val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager 15 | 16 | init { 17 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { 18 | createNotificationChannel() 19 | } 20 | } 21 | 22 | fun showNotification(title: String, text: String, priority: Int) { 23 | val notificationBuilder = 24 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { 25 | Notification.Builder(context, channelId) 26 | } else { 27 | Notification.Builder(context) 28 | } 29 | 30 | val notification = 31 | notificationBuilder.setSmallIcon(R.drawable.ic_launcher_notification) // the status icon 32 | .setTicker(text) // the status text 33 | .setWhen(System.currentTimeMillis()) // the time stamp 34 | .setContentTitle(title) // the label of the entry 35 | .setContentText(text) // the contents of the entry 36 | .setPriority(priority) 37 | .build() 38 | 39 | val notificationId = 40 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { 41 | ThreadLocalRandom.current().nextInt() 42 | } else { 43 | 13737 44 | } 45 | nm.notify(notificationId, notification) 46 | } 47 | 48 | @RequiresApi(26) 49 | private fun createNotificationChannel() { 50 | val name = context.packageName 51 | val descriptionText = "Test notifications" 52 | val importance = NotificationManager.IMPORTANCE_DEFAULT 53 | val channel = 54 | NotificationChannel(channelId, name, importance).apply { 55 | description = descriptionText 56 | } 57 | nm.createNotificationChannel(channel) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /app/src/main/java/org/unifiedpush/example/utils/RawRequest.kt: -------------------------------------------------------------------------------- 1 | package org.unifiedpush.example.utils 2 | 3 | import androidx.annotation.GuardedBy 4 | import com.android.volley.NetworkResponse 5 | import com.android.volley.Request 6 | import com.android.volley.Response 7 | import com.android.volley.toolbox.HttpHeaderParser 8 | 9 | /** 10 | * Creates a new request with the given method. 11 | * 12 | * @param method the request method to use 13 | * @param url URL to fetch the string at 14 | * @param mListener Listener to receive the NetworkResponse response 15 | * @param errorListener Error listener, or null to ignore errors 16 | */ 17 | open class RawRequest( 18 | method: Int, 19 | url: String?, 20 | @field:GuardedBy("mLock") private var mListener: Response.Listener?, 21 | errorListener: Response.ErrorListener? 22 | ) : 23 | Request(method, url, errorListener) { 24 | /** Lock to guard mListener as it is cleared on cancel() and read on delivery. */ 25 | private val mLock = Any() 26 | 27 | /** 28 | * Creates a new GET request. 29 | * 30 | * @param url URL to fetch the string at 31 | * @param listener Listener to receive the NetworkResponse response 32 | * @param errorListener Error listener, or null to ignore errors 33 | */ 34 | constructor( 35 | url: String?, 36 | listener: Response.Listener?, 37 | errorListener: Response.ErrorListener? 38 | ) : this( 39 | Method.GET, 40 | url, 41 | listener, 42 | errorListener 43 | ) 44 | 45 | override fun cancel() { 46 | super.cancel() 47 | synchronized(mLock) { 48 | mListener = null 49 | } 50 | } 51 | 52 | override fun deliverResponse(response: NetworkResponse) { 53 | var listener: Response.Listener? 54 | synchronized(mLock) { 55 | listener = mListener 56 | } 57 | if (listener != null) { 58 | listener!!.onResponse(response) 59 | } 60 | } 61 | 62 | override fun parseNetworkResponse(response: NetworkResponse): Response { 63 | return Response.success(response, HttpHeaderParser.parseCacheHeaders(response)) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /app/src/main/java/org/unifiedpush/example/utils/RegistrationDialogs.kt: -------------------------------------------------------------------------------- 1 | package org.unifiedpush.example.utils 2 | 3 | import android.content.Context 4 | import org.unifiedpush.android.connector.UnifiedPush 5 | import org.unifiedpush.android.connector.ui.SelectDistributorDialogsBuilder 6 | import org.unifiedpush.android.connector.ui.UnifiedPushFunctions 7 | import org.unifiedpush.example.Store 8 | 9 | class RegistrationDialogs( 10 | context: Context, 11 | override var mayUseCurrent: Boolean, 12 | override var mayUseDefault: Boolean 13 | ) : SelectDistributorDialogsBuilder( 14 | context, 15 | object : UnifiedPushFunctions { 16 | override fun tryUseDefaultDistributor(callback: (Boolean) -> Unit) { 17 | UnifiedPush.tryUseDefaultDistributor(context, callback) 18 | } 19 | 20 | override fun getAckDistributor(): String? { 21 | return UnifiedPush.getAckDistributor(context) 22 | } 23 | 24 | override fun getDistributors(): List { 25 | return UnifiedPush.getDistributors(context) 26 | } 27 | 28 | override fun register(instance: String) { 29 | val store = Store(context) 30 | val vapid = if (store.devMode && store.devUseVapid) store.vapidPubKey else null 31 | UnifiedPush.register(context, instance, vapid = vapid) 32 | } 33 | 34 | override fun saveDistributor(distributor: String) { 35 | UnifiedPush.saveDistributor(context, distributor) 36 | } 37 | } 38 | ) 39 | /* { 40 | /** 41 | * This is an example to ignore noDistributorFound 42 | */ 43 | override fun onNoDistributorFound() { 44 | // DO NOTHING 45 | } 46 | } */ 47 | -------------------------------------------------------------------------------- /app/src/main/java/org/unifiedpush/example/utils/Tag.kt: -------------------------------------------------------------------------------- 1 | package org.unifiedpush.example.utils 2 | 3 | val Any.TAG: String 4 | get() { 5 | val tag = javaClass.simpleName 6 | return if (tag.length <= 23) tag else tag.substring(0, 23) 7 | } 8 | -------------------------------------------------------------------------------- /app/src/main/java/org/unifiedpush/example/utils/Utils.kt: -------------------------------------------------------------------------------- 1 | package org.unifiedpush.example.utils 2 | 3 | import android.os.Build 4 | import android.util.Base64 5 | import com.google.crypto.tink.subtle.EllipticCurves 6 | import java.security.KeyFactory 7 | import java.security.interfaces.ECPublicKey 8 | import java.security.spec.ECPublicKeySpec 9 | 10 | /** 11 | * Do we have implemented VAPID for the SDK of the phone ? 12 | */ 13 | fun vapidImplementedForSdk(): Boolean { 14 | return Build.VERSION.SDK_INT >= 23 15 | } 16 | 17 | /** 18 | * Decode [ECPublicKey] from [String] 19 | */ 20 | fun String.decodePubKey(): ECPublicKey { 21 | val point = 22 | EllipticCurves.pointDecode( 23 | EllipticCurves.CurveType.NIST_P256, 24 | EllipticCurves.PointFormatType.UNCOMPRESSED, 25 | this.b64decode() 26 | ) 27 | val spec = EllipticCurves.getCurveSpec(EllipticCurves.CurveType.NIST_P256) 28 | return KeyFactory.getInstance("EC").generatePublic(ECPublicKeySpec(point, spec)) as ECPublicKey 29 | } 30 | 31 | /** 32 | * Encode [ECPublicKey] to [String] 33 | */ 34 | fun ECPublicKey.encode(): String { 35 | val points = 36 | EllipticCurves.pointEncode( 37 | EllipticCurves.CurveType.NIST_P256, 38 | EllipticCurves.PointFormatType.UNCOMPRESSED, 39 | this.w 40 | ) 41 | return points.b64encode() 42 | } 43 | 44 | /** 45 | * Base64 decode, url safe, no padding 46 | */ 47 | fun String.b64decode(): ByteArray { 48 | return Base64.decode( 49 | this, 50 | Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING 51 | ) 52 | } 53 | 54 | /** 55 | * Base64 encode, url safe, no padding 56 | */ 57 | fun ByteArray.b64encode(): String { 58 | return Base64.encode( 59 | this, 60 | Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING 61 | ).toString(Charsets.UTF_8) 62 | } 63 | 64 | /** 65 | * Generate test page URL for parameters 66 | */ 67 | fun genTestPageUrl(endpoint: String, p256dh: String, auth: String, vapid: String, showVapid: Boolean): String { 68 | var url = "https://unifiedpush.org/test_wp.html#endpoint=$endpoint&p256dh=$p256dh&auth=$auth" 69 | if (showVapid) { 70 | url += "&vapid=$vapid" 71 | } 72 | return url 73 | } 74 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 14 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_notification.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 14 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/UnifiedPush/android-example/f6923ce14445ac78866f0cb376211f4540481b88/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/UnifiedPush/android-example/f6923ce14445ac78866f0cb376211f4540481b88/app/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/UnifiedPush/android-example/f6923ce14445ac78866f0cb376211f4540481b88/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/UnifiedPush/android-example/f6923ce14445ac78866f0cb376211f4540481b88/app/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/UnifiedPush/android-example/f6923ce14445ac78866f0cb376211f4540481b88/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/UnifiedPush/android-example/f6923ce14445ac78866f0cb376211f4540481b88/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/UnifiedPush/android-example/f6923ce14445ac78866f0cb376211f4540481b88/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/UnifiedPush/android-example/f6923ce14445ac78866f0cb376211f4540481b88/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/UnifiedPush/android-example/f6923ce14445ac78866f0cb376211f4540481b88/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/UnifiedPush/android-example/f6923ce14445ac78866f0cb376211f4540481b88/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/values/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #8445DC 4 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | UP-Example 3 | This is an example of an application using the UnifiedPush library.\n\nBy clicking on the registration button, you will have to choose between the distributors installed on your device the one that will provide the push notifications. 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 |