├── .github ├── dependabot.yml └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── LICENSE ├── README.md ├── app ├── .gitignore ├── build.gradle.kts ├── google-services.json ├── proguard-rules.pro └── src │ ├── appcenter │ └── kotlin │ │ └── top │ │ └── learningman │ │ └── push │ │ └── Checker.kt │ ├── free │ └── kotlin │ │ └── top │ │ └── learningman │ │ └── push │ │ └── Checker.kt │ ├── github │ └── kotlin │ │ └── top │ │ └── learningman │ │ └── push │ │ └── Checker.kt │ ├── main │ ├── AndroidManifest.xml │ ├── ic_launcher-playstore.png │ ├── kotlin │ │ └── top │ │ │ └── learningman │ │ │ └── push │ │ │ ├── Constant.kt │ │ │ ├── activity │ │ │ ├── MainActivity.kt │ │ │ ├── MessagesActivity.kt │ │ │ ├── SettingsActivity.kt │ │ │ ├── SetupActivity.kt │ │ │ └── TranslucentActivity.kt │ │ │ ├── application │ │ │ └── MainApplication.kt │ │ │ ├── data │ │ │ └── Repo.kt │ │ │ ├── entity │ │ │ ├── Message.kt │ │ │ └── MessageAdapter.kt │ │ │ ├── provider │ │ │ ├── Channel.kt │ │ │ ├── FCM.kt │ │ │ └── WebSocket.kt │ │ │ ├── service │ │ │ ├── BootReceiver.kt │ │ │ ├── ReceiverService.kt │ │ │ ├── Utils.kt │ │ │ └── websocket │ │ │ │ └── WebSocketSessionManager.kt │ │ │ ├── utils │ │ │ ├── Extensions.kt │ │ │ ├── Markwon.kt │ │ │ ├── Network.kt │ │ │ └── PermissionManager.kt │ │ │ ├── view │ │ │ └── MessageDialog.kt │ │ │ └── viewModel │ │ │ └── MessageViewModel.kt │ └── res │ │ ├── drawable │ │ ├── ic_launcher_background.xml │ │ ├── ic_launcher_foreground.xml │ │ └── ic_message.xml │ │ ├── layout │ │ ├── activity_main.xml │ │ ├── activity_messages.xml │ │ ├── activity_settings.xml │ │ ├── activity_setup.xml │ │ ├── activity_translucent.xml │ │ ├── message_dialog.xml │ │ └── text_row_item.xml │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── values │ │ ├── colors.xml │ │ ├── dimens.xml │ │ ├── strings.xml │ │ └── themes.xml │ │ └── xml │ │ └── root_preferences.xml │ ├── play │ └── kotlin │ │ └── top │ │ └── learningman │ │ └── push │ │ └── Checker.kt │ └── release │ └── res │ └── xml │ └── network_security_config.xml ├── buildSrc ├── build.gradle.kts └── src │ └── main │ └── java │ └── dev │ └── zxilly │ └── gradle │ ├── helper.kt │ └── setup.gradle.kts ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── release.jks ├── scripts ├── appcenter.sh ├── release_check.sh └── whats_new.sh ├── settings.gradle.kts └── static └── google-play-badge.png /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gradle" 4 | directory: "/app" 5 | schedule: 6 | interval: "daily" 7 | 8 | - package-ecosystem: "github-actions" 9 | directory: "/" 10 | schedule: 11 | interval: "daily" 12 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Build and release 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | permissions: write-all 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Get current time 15 | uses: 1466587594/get-current-time@v2 16 | id: current-time 17 | with: 18 | format: YYYYMMDDTHHmmss 19 | utcOffset: "+08:00" 20 | 21 | - uses: actions/checkout@v4 22 | 23 | - name: Fetch tags 24 | run: git fetch --prune --unshallow --tags 25 | 26 | - name: Grant permission for scripts 27 | run: chmod +x ./scripts/*.sh 28 | 29 | - name: Check should release 30 | id: release 31 | run: ./scripts/release_check.sh 32 | 33 | - name: set up JDK 17 34 | uses: actions/setup-java@v4 35 | with: 36 | java-version: '17' 37 | distribution: 'temurin' 38 | cache: gradle 39 | 40 | - name: Grant execute permission for gradlew 41 | run: chmod +x gradlew 42 | 43 | - name: Build apks 44 | uses: gradle/gradle-build-action@v3 45 | with: 46 | arguments: assembleRelease --scan 47 | env: 48 | PASSWORD: "${{ secrets.PASSWORD }}" 49 | GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" 50 | 51 | - name: Build aab 52 | if: steps.release.outputs.release == 'true' 53 | uses: gradle/gradle-build-action@v3 54 | with: 55 | arguments: bundlePlayRelease --scan 56 | env: 57 | PASSWORD: "${{ secrets.PASSWORD }}" 58 | GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" 59 | 60 | - name: PreRelease Package 61 | uses: marvinpinto/action-automatic-releases@latest 62 | if: steps.release.outputs.release == 'false' 63 | with: 64 | repo_token: "${{ secrets.GITHUB_TOKEN }}" 65 | automatic_release_tag: "nightly" 66 | prerelease: true 67 | files: | 68 | app/build/outputs/apk/github/release/* 69 | app/build/outputs/apk/free/release/app-free-release.apk 70 | app/build/outputs/apk/appcenter/release/app-appcenter-release.apk 71 | 72 | - name: Release Package 73 | uses: marvinpinto/action-automatic-releases@latest 74 | if: steps.release.outputs.release == 'true' 75 | with: 76 | repo_token: "${{ secrets.GITHUB_TOKEN }}" 77 | automatic_release_tag: "${{ steps.release.outputs.version }}" 78 | prerelease: false 79 | files: | 80 | app/build/outputs/apk/github/release/* 81 | app/build/outputs/apk/free/release/app-free-release.apk 82 | app/build/outputs/apk/appcenter/release/app-appcenter-release.apk 83 | app/build/outputs/bundle/playRelease/app-play-release.aab 84 | 85 | - name: Get apk info 86 | id: apk-info 87 | uses: hkusu/apk-info-action@v1 88 | with: 89 | apk-path: app/build/outputs/apk/appcenter/release/app-appcenter-release.apk 90 | 91 | - name: Setup AppCenter CLI 92 | run: | 93 | npm install -g appcenter-cli 94 | 95 | - name: Upload artifact to App Center 96 | run: | 97 | ./scripts/appcenter.sh 98 | shell: bash 99 | env: 100 | VERSION_CODE: ${{ steps.apk-info.outputs.version-code }} 101 | VERSION_NAME: ${{ steps.apk-info.outputs.version-name }} 102 | APPCENTER_TOKEN: ${{ secrets.APP_CENTER_TOKEN }} 103 | 104 | - name: Create whatsNew 105 | if: steps.release.outputs.release == 'true' 106 | run: | 107 | ./scripts/whats_new.sh 108 | env: 109 | VERSION: ${{ steps.release.outputs.version }} 110 | 111 | - name: Upload Android Release to Play Store 112 | uses: r0adkll/upload-google-play@v1.1.3 113 | if: steps.release.outputs.release == 'true' 114 | with: 115 | releaseFiles: app/build/outputs/bundle/playRelease/app-play-release.aab 116 | serviceAccountJsonPlainText: ${{ secrets.SERVICE_ACCOUNT_JSON }} 117 | packageName: top.learningman.push 118 | track: beta 119 | mappingFile: app/build/outputs/mapping/playRelease/mapping.txt 120 | whatsNewDirectory: whatsNew 121 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Build Test 2 | 3 | on: 4 | pull_request: 5 | 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | 12 | - name: set up JDK 17 13 | uses: actions/setup-java@v4 14 | with: 15 | java-version: '17' 16 | distribution: 'temurin' 17 | cache: gradle 18 | 19 | - name: Grant execute permission for gradlew 20 | run: chmod +x gradlew 21 | 22 | - uses: gradle/gradle-build-action@v3 23 | with: 24 | arguments: assembleUnsigned --scan 25 | env: 26 | PASSWORD: "${{ secrets.PASSWORD }}" 27 | GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | ### Android template 3 | # Built application files 4 | *.apk 5 | *.ap_ 6 | *.aab 7 | 8 | # Files for the ART/Dalvik VM 9 | *.dex 10 | 11 | # Java class files 12 | *.class 13 | 14 | # Generated files 15 | bin/ 16 | gen/ 17 | out/ 18 | 19 | # Gradle files 20 | .gradle/ 21 | build/ 22 | 23 | # Local configuration file (sdk path, etc) 24 | local.properties 25 | 26 | # Proguard folder generated by Eclipse 27 | proguard/ 28 | 29 | # Log Files 30 | *.log 31 | 32 | # Android Studio Navigation editor temp files 33 | .navigation/ 34 | 35 | # Android Studio captures folder 36 | captures/ 37 | 38 | # IntelliJ 39 | *.iml 40 | .idea/workspace.xml 41 | .idea/tasks.xml 42 | .idea/gradle.xml 43 | .idea/assetWizardSettings.xml 44 | .idea/dictionaries 45 | .idea/libraries 46 | # Android Studio 3 in .gitignore file. 47 | .idea/caches 48 | .idea/modules.xml 49 | # Comment next line if keeping position of elements in Navigation Editor is relevant for you 50 | .idea/navEditor.xml 51 | 52 | # Keystore files 53 | # Uncomment the following lines if you do not want to check your keystore files in. 54 | #*.jks 55 | #*.keystore 56 | 57 | # External native build folder generated in Android Studio 2.2 and later 58 | .externalNativeBuild 59 | .cxx/ 60 | 61 | # Google Services (e.g. APIs or Firebase) 62 | # google-services.json 63 | 64 | # Freeline 65 | freeline.py 66 | freeline/ 67 | freeline_project_description.json 68 | 69 | # fastlane 70 | fastlane/report.xml 71 | fastlane/Preview.html 72 | fastlane/screenshots 73 | fastlane/test_output 74 | fastlane/readme.md 75 | 76 | # Version control 77 | vcs.xml 78 | 79 | # lint 80 | lint/intermediates/ 81 | lint/generated/ 82 | lint/outputs/ 83 | lint/tmp/ 84 | # lint/reports/ 85 | 86 | # Android Profiling 87 | *.hprof 88 | 89 | ### JetBrains template 90 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 91 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 92 | 93 | # User-specific stuff 94 | .idea/**/workspace.xml 95 | .idea/**/tasks.xml 96 | .idea/**/usage.statistics.xml 97 | .idea/**/dictionaries 98 | .idea/**/shelf 99 | 100 | # Generated files 101 | .idea/**/contentModel.xml 102 | 103 | # Sensitive or high-churn files 104 | .idea/**/dataSources/ 105 | .idea/**/dataSources.ids 106 | .idea/**/dataSources.local.xml 107 | .idea/**/sqlDataSources.xml 108 | .idea/**/dynamic.xml 109 | .idea/**/uiDesigner.xml 110 | .idea/**/dbnavigator.xml 111 | 112 | # Gradle 113 | .idea/**/gradle.xml 114 | .idea/**/libraries 115 | 116 | # Gradle and Maven with auto-import 117 | # When using Gradle or Maven with auto-import, you should exclude module files, 118 | # since they will be recreated, and may cause churn. Uncomment if using 119 | # auto-import. 120 | .idea/artifacts 121 | .idea/compiler.xml 122 | .idea/jarRepositories.xml 123 | 124 | .idea/*.iml 125 | .idea/modules 126 | 127 | *.ipr 128 | 129 | # CMake 130 | cmake-build-*/ 131 | 132 | # Mongo Explorer plugin 133 | .idea/**/mongoSettings.xml 134 | 135 | # File-based project format 136 | *.iws 137 | 138 | # IntelliJ 139 | 140 | # mpeltonen/sbt-idea plugin 141 | .idea_modules/ 142 | 143 | # JIRA plugin 144 | atlassian-ide-plugin.xml 145 | 146 | # Cursive Clojure plugin 147 | .idea/replstate.xml 148 | 149 | # Crashlytics plugin (for Android Studio and IntelliJ) 150 | com_crashlytics_export_strings.xml 151 | crashlytics.properties 152 | crashlytics-build.properties 153 | fabric.properties 154 | 155 | # Editor-based Rest Client 156 | .idea/httpRequests 157 | 158 | # Android studio 3.1+ serialized cache file 159 | .idea/caches/build_file_checksums.ser 160 | 161 | ### Example user template template 162 | ### Example user template 163 | 164 | # IntelliJ project files 165 | .idea 166 | out 167 | gen 168 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2021, Zxilly 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Notify 2 | 3 | 通过各移动设备的推送服务,接收通知。 4 | 5 | 当前支持的推送渠道有: 6 | 7 | - 【FCM】:Google Firebase Cloud Messaging 8 | - 【WebSocket】:WebSocket 长连接 9 | 10 | 联系 @Zxilly 获取 `https://push.learningman.top` 使用权限。 11 | 12 | 查看 [server](https://github.com/ZNotify/server) 了解服务端。 13 | 14 | ## Download 15 | 16 | [AppCenter](https://install.appcenter.ms/users/zxilly/apps/notify/distribution_groups/public) 17 | 18 | [Github Release](https://github.com/ZNotify/android/releases) 19 | 20 | 21 | 22 | **提示:** 23 | 24 | `app-free-release.apk` 不包含应用内更新。 25 | 26 | `app-github-release.apk` 包含从 `Github` 下载应用内更新的代码。 27 | 28 | `AppCenter` 的应用内更新由 `AppCenter` 提供。 29 | 30 | `Google Play` 的应用内更新由 `Google Play In-app` 提供。 31 | 32 | ## TODO 33 | 34 | - 支持推送到指定设备 35 | - 支持推送到指定渠道 36 | - 支持设备端选择推送渠道 37 | 38 | ## Self-hosting 39 | 40 | 通常,你应该修改 41 | 42 | - `app/build.gradle` > `defaultConfig.applicationId` 43 | - `app/src/main/kotlin/top/learningman/push/Constant.kt` 44 | - `app/google-services.json` 45 | 46 | ## License 47 | 48 | Distribute under BSD 3-Clause License -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /app/build.gradle.kts: -------------------------------------------------------------------------------- 1 | @file:Suppress("UnstableApiUsage") 2 | 3 | import dev.zxilly.gradle.exec 4 | 5 | plugins { 6 | id("com.android.application") version "8.2.0" 7 | 8 | val ktVersion = "2.1.0" 9 | 10 | kotlin("android") version ktVersion 11 | kotlin("plugin.serialization") version ktVersion 12 | 13 | id("com.google.gms.google-services") version "4.4.0" 14 | id("dev.zxilly.gradle.keeper") version "0.1.0" 15 | } 16 | 17 | val isCI = System.getenv("CI") == "true" 18 | 19 | keeper { 20 | expectValue = false 21 | 22 | if (isCI) { 23 | environment(nameMapping = true) 24 | } else { 25 | properties() 26 | } 27 | } 28 | 29 | 30 | val gitCommitId = "git rev-parse --short HEAD".exec() 31 | val gitLastCommitMessage = "git log -1 --pretty=%B".exec() 32 | 33 | val isRelease = gitLastCommitMessage.contains("[release") 34 | 35 | val isDebug = gradle.startParameter.taskRequests.any { req -> 36 | req.args.any { it.endsWith("Debug") } 37 | } 38 | 39 | val buildType = if (isDebug) ".debug" else "" 40 | 41 | //get current timestamp 42 | val currentVersionCode = System.currentTimeMillis() / 1000 43 | 44 | var baseVersionName = "1.0.0" 45 | 46 | if (isCI) { 47 | val currentEvent = System.getenv("GITHUB_EVENT_NAME") 48 | if (currentEvent == "push") { 49 | baseVersionName = if (isRelease) { 50 | val versionAll = gitLastCommitMessage.split("[release:")[1] 51 | val version = versionAll.split("]")[0].trim() 52 | version 53 | } else { 54 | val branch = System.getenv("GITHUB_REF_NAME") 55 | ?: throw IllegalArgumentException("GITHUB_REF_NAME is not set") 56 | "$branch.$gitCommitId" 57 | } 58 | } 59 | } 60 | 61 | val versionBase = "${baseVersionName}${buildType}" 62 | 63 | android { 64 | defaultConfig { 65 | applicationId = "top.learningman.push" 66 | minSdk = 28 67 | targetSdk = 34 68 | versionCode = currentVersionCode.toInt() 69 | versionName = versionBase 70 | } 71 | 72 | signingConfigs { 73 | create("auto") { 74 | val password = secret.get("password") 75 | 76 | storeFile = file("../release.jks") 77 | keyAlias = "key" 78 | storePassword = password 79 | keyPassword = password 80 | } 81 | } 82 | compileSdk = 34 83 | 84 | buildTypes { 85 | create("unsigned") { 86 | signingConfig = null 87 | } 88 | 89 | debug { 90 | signingConfig = signingConfigs.getByName("auto") 91 | } 92 | release { 93 | signingConfig = signingConfigs.getByName("auto") 94 | isMinifyEnabled = true 95 | proguardFiles( 96 | getDefaultProguardFile("proguard-android-optimize.txt"), 97 | "proguard-rules.pro" 98 | ) 99 | } 100 | } 101 | compileOptions { 102 | sourceCompatibility = JavaVersion.VERSION_1_8 103 | targetCompatibility = JavaVersion.VERSION_1_8 104 | } 105 | kotlinOptions { 106 | jvmTarget = "1.8" 107 | freeCompilerArgs += listOf( 108 | "-P", 109 | "plugin:androidx.compose.compiler.plugins.kotlin:suppressKotlinVersionCompatibilityCheck=true" 110 | ) 111 | } 112 | packaging { 113 | resources { 114 | excludes += "META-INF/AL2.0" 115 | excludes += "META-INF/LGPL2.1" 116 | excludes += "META-INF/atomicfu.kotlin_module" 117 | } 118 | } 119 | viewBinding { 120 | enable = true 121 | } 122 | 123 | namespace = "top.learningman.push" 124 | 125 | buildFeatures { 126 | viewBinding = true 127 | compose = true 128 | buildConfig = true 129 | } 130 | 131 | composeOptions { 132 | kotlinCompilerExtensionVersion = "1.5.5-dev-k1.9.21-163bb051fe5" 133 | } 134 | dependenciesInfo { 135 | includeInApk = false 136 | includeInBundle = false 137 | } 138 | 139 | flavorDimensions += listOf("pub") 140 | productFlavors { 141 | create("free") { 142 | dimension = "pub" 143 | versionNameSuffix = "(free)" 144 | } 145 | create("github") { 146 | dimension = "pub" 147 | versionNameSuffix = "(github)" 148 | } 149 | create("appcenter") { 150 | dimension = "pub" 151 | versionNameSuffix = "(appcenter)" 152 | } 153 | create("play") { 154 | dimension = "pub" 155 | versionNameSuffix = "(play)" 156 | } 157 | } 158 | 159 | } 160 | 161 | dependencies { 162 | implementation("androidx.core:core-ktx:1.12.0") 163 | implementation("androidx.core:core-splashscreen:1.0.1") 164 | implementation("androidx.appcompat:appcompat:1.6.1") 165 | 166 | implementation("androidx.activity:activity-compose:1.8.1") 167 | implementation("androidx.compose.material3:material3:1.1.2") 168 | implementation("androidx.compose.material3:material3-window-size-class:1.1.2") 169 | implementation("androidx.compose.animation:animation:1.5.4") 170 | implementation("androidx.compose.ui:ui-tooling:1.5.4") 171 | implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.2") 172 | implementation("com.google.android.material:compose-theme-adapter:1.2.1") 173 | 174 | implementation("com.google.android.material:material:1.10.0") 175 | 176 | implementation(platform("com.google.firebase:firebase-bom:31.1.1")) 177 | implementation("com.google.firebase:firebase-messaging-ktx") 178 | implementation("androidx.core:core-ktx:1.12.0") 179 | 180 | val playImplementation by configurations 181 | playImplementation("com.google.android.play:app-update:2.1.0") 182 | playImplementation("com.google.android.play:app-update-ktx:2.1.0") 183 | 184 | implementation("androidx.constraintlayout:constraintlayout:2.1.4") 185 | implementation("androidx.navigation:navigation-fragment-ktx:2.7.5") 186 | implementation("androidx.navigation:navigation-ui-ktx:2.7.5") 187 | 188 | val lifecycleVersion = "2.6.2" 189 | implementation("androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion") 190 | implementation("androidx.lifecycle:lifecycle-runtime-ktx:$lifecycleVersion") 191 | implementation("androidx.lifecycle:lifecycle-service:$lifecycleVersion") 192 | implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion") 193 | implementation("androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion") 194 | 195 | implementation("androidx.fragment:fragment-ktx:1.6.2") 196 | implementation("androidx.activity:activity-ktx:1.8.1") 197 | implementation("androidx.preference:preference-ktx:1.2.1") 198 | 199 | implementation("androidx.browser:browser:1.7.0") 200 | 201 | implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") 202 | 203 | implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.1") 204 | implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.1") 205 | 206 | implementation("com.github.code-mc:material-icon-lib:1.1.5") 207 | 208 | val ktorVersion = "3.0.3" 209 | implementation("io.ktor:ktor-client-core:$ktorVersion") 210 | implementation("io.ktor:ktor-client-websockets:$ktorVersion") 211 | implementation("io.ktor:ktor-client-okhttp:$ktorVersion") 212 | 213 | implementation("dev.zxilly:notify-sdk:2.3.3") 214 | 215 | val markwonVersion = "4.6.2" 216 | implementation("io.noties.markwon:core:${markwonVersion}") 217 | implementation("io.noties.markwon:ext-tables:${markwonVersion}") 218 | implementation("io.noties.markwon:html:${markwonVersion}") 219 | implementation("io.noties.markwon:image:${markwonVersion}") 220 | 221 | val appCenterSdkVersion = "5.0.5" 222 | implementation("com.microsoft.appcenter:appcenter-analytics:${appCenterSdkVersion}") 223 | implementation("com.microsoft.appcenter:appcenter-crashes:${appCenterSdkVersion}") 224 | 225 | implementation("com.github.Zxilly:SetupWizardLib:master-SNAPSHOT") 226 | implementation("com.github.XomaDev:MIUI-autostart:master-SNAPSHOT") 227 | 228 | val githubImplementation by configurations 229 | val appcenterImplementation by configurations 230 | val upgraderVersion = "nightly.18f8e0e" 231 | githubImplementation("dev.zxilly.lib:upgrader:$upgraderVersion") 232 | appcenterImplementation("dev.zxilly.lib:upgrader:$upgraderVersion") 233 | } -------------------------------------------------------------------------------- /app/google-services.json: -------------------------------------------------------------------------------- 1 | { 2 | "project_info": { 3 | "project_number": "741406262501", 4 | "project_id": "notify-zx", 5 | "storage_bucket": "notify-zx.appspot.com" 6 | }, 7 | "client": [ 8 | { 9 | "client_info": { 10 | "mobilesdk_app_id": "1:741406262501:android:5498ca59888135db50800c", 11 | "android_client_info": { 12 | "package_name": "top.learningman.push" 13 | } 14 | }, 15 | "oauth_client": [ 16 | { 17 | "client_id": "741406262501-6fdojsensngo0na2ffqlabsbmk6nc10m.apps.googleusercontent.com", 18 | "client_type": 3 19 | } 20 | ], 21 | "api_key": [ 22 | { 23 | "current_key": "AIzaSyAodFEVoiJvRgCMZlvNUJCt35BQYPt2jCY" 24 | } 25 | ], 26 | "services": { 27 | "appinvite_service": { 28 | "other_platform_oauth_client": [ 29 | { 30 | "client_id": "741406262501-6fdojsensngo0na2ffqlabsbmk6nc10m.apps.googleusercontent.com", 31 | "client_type": 3 32 | } 33 | ] 34 | } 35 | } 36 | } 37 | ], 38 | "configuration_version": "1" 39 | } -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | -keep class dev.zxilly.notify.sdk.** {*;} 2 | -keep class dev.zxilly.notify.sdk.entity.** {*;} 3 | -keep class top.learningman.push.** {*;} 4 | 5 | -dontwarn com.caverock.androidsvg.SVG 6 | -dontwarn com.caverock.androidsvg.SVGParseException 7 | -dontwarn org.bouncycastle.jsse.BCSSLParameters 8 | -dontwarn org.bouncycastle.jsse.BCSSLSocket 9 | -dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider 10 | -dontwarn org.commonmark.ext.gfm.strikethrough.Strikethrough 11 | -dontwarn org.conscrypt.Conscrypt$Version 12 | -dontwarn org.conscrypt.Conscrypt 13 | -dontwarn org.conscrypt.ConscryptHostnameVerifier 14 | -dontwarn org.openjsse.javax.net.ssl.SSLParameters 15 | -dontwarn org.openjsse.javax.net.ssl.SSLSocket 16 | -dontwarn org.openjsse.net.ssl.OpenJSSE 17 | -dontwarn org.slf4j.impl.StaticLoggerBinder 18 | -dontwarn pl.droidsonroids.gif.GifDrawable -------------------------------------------------------------------------------- /app/src/appcenter/kotlin/top/learningman/push/Checker.kt: -------------------------------------------------------------------------------- 1 | package top.learningman.push 2 | 3 | import android.app.Application 4 | import android.content.Context 5 | import android.util.Log 6 | import com.microsoft.appcenter.crashes.Crashes 7 | import dev.zxilly.lib.upgrader.Upgrader 8 | import dev.zxilly.lib.upgrader.checker.AppCenterChecker 9 | import top.learningman.push.activity.TranslucentActivity 10 | 11 | fun checkerInit(app: Application) { 12 | Upgrader.init( 13 | app, Upgrader.Companion.Config( 14 | AppCenterChecker("0c045975-212b-441d-9ee4-e6ab9c76f8a3"), 15 | listOf(TranslucentActivity::class.java) 16 | ) 17 | ) 18 | } 19 | 20 | @Suppress("UNUSED_PARAMETER") 21 | fun checkUpgrade(context: Context) { 22 | runCatching { 23 | Upgrader.getInstance()?.tryUpgrade()?: Log.e("Checker", "Upgrader is null") 24 | }.onFailure { 25 | Log.e("Upgrader", "Failed to check upgrade", it) 26 | Crashes.trackError(it) 27 | } 28 | } -------------------------------------------------------------------------------- /app/src/free/kotlin/top/learningman/push/Checker.kt: -------------------------------------------------------------------------------- 1 | package top.learningman.push 2 | 3 | import android.app.Application 4 | import android.content.Context 5 | import android.content.Intent 6 | import android.net.Uri 7 | 8 | @Suppress("UNUSED_PARAMETER") 9 | fun checkerInit(app: Application) { 10 | } 11 | 12 | @Suppress("UNUSED_PARAMETER") 13 | fun checkUpgrade(context: Context) { 14 | } -------------------------------------------------------------------------------- /app/src/github/kotlin/top/learningman/push/Checker.kt: -------------------------------------------------------------------------------- 1 | package top.learningman.push 2 | 3 | import android.app.Application 4 | import android.content.Context 5 | import android.util.Log 6 | import com.microsoft.appcenter.crashes.Crashes 7 | import dev.zxilly.lib.upgrader.Upgrader 8 | import dev.zxilly.lib.upgrader.checker.GitHubRMCConfig 9 | import dev.zxilly.lib.upgrader.checker.GitHubReleaseMetadataChecker 10 | import top.learningman.push.activity.TranslucentActivity 11 | 12 | internal fun checkerInit(app: Application) { 13 | runCatching { 14 | Upgrader.init( 15 | app, Upgrader.Companion.Config( 16 | GitHubReleaseMetadataChecker( 17 | GitHubRMCConfig( 18 | owner = "ZNotify", 19 | repo = "android", 20 | upgradeChannel = GitHubRMCConfig.UpgradeChannel.PRE_RELEASE 21 | ) 22 | ), 23 | listOf(TranslucentActivity::class.java) 24 | ) 25 | ) 26 | }.onFailure { 27 | Log.e("Upgrader", "Failed to initialize Upgrader", it) 28 | Crashes.trackError(it) 29 | } 30 | } 31 | 32 | @Suppress("UNUSED_PARAMETER") 33 | internal fun checkUpgrade(context: Context) { 34 | Upgrader.getInstance()?.tryUpgrade() 35 | } 36 | -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 21 | 22 | 26 | 27 | 30 | 31 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 47 | 51 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 66 | 67 | 68 | 69 | 70 | 71 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | -------------------------------------------------------------------------------- /app/src/main/ic_launcher-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZNotify/android/33735900f09f934e453813bc605a1eb53b1e46b9/app/src/main/ic_launcher-playstore.png -------------------------------------------------------------------------------- /app/src/main/kotlin/top/learningman/push/Constant.kt: -------------------------------------------------------------------------------- 1 | package top.learningman.push 2 | 3 | object Constant { 4 | private val ENDPOINT_LIST = arrayOf("push.learningman.top", "192.168.1.111:14444") 5 | private const val ENDPOINT_INDEX = 1 6 | 7 | private val HOST: String 8 | get() { 9 | return ENDPOINT_LIST[ENDPOINT_INDEX] 10 | } 11 | 12 | private val USE_SECURE_PROTOCOL: Boolean 13 | get() { 14 | return true 15 | } 16 | 17 | private val HTTP_PROTOCOL: String 18 | get() { 19 | if (USE_SECURE_PROTOCOL) { 20 | return "https://" 21 | } 22 | return "http://" 23 | } 24 | 25 | private val WEBSOCKET_PROTOCOL: String 26 | get() { 27 | if (USE_SECURE_PROTOCOL) { 28 | return "wss://" 29 | } 30 | return "ws://" 31 | } 32 | 33 | const val APP_CENTER_SECRET = "0c045975-212b-441d-9ee4-e6ab9c76f8a3" 34 | 35 | val API_ENDPOINT = "${HTTP_PROTOCOL}${HOST}" 36 | val API_WS_ENDPOINT = "${WEBSOCKET_PROTOCOL}${HOST}" 37 | } 38 | -------------------------------------------------------------------------------- /app/src/main/kotlin/top/learningman/push/activity/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package top.learningman.push.activity 2 | 3 | import android.annotation.SuppressLint 4 | import android.content.Intent 5 | import android.content.res.ColorStateList 6 | import android.os.Bundle 7 | import android.util.Log 8 | import android.view.View 9 | import android.widget.Toast 10 | import androidx.activity.result.contract.ActivityResultContracts 11 | import androidx.appcompat.app.AppCompatActivity 12 | import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen 13 | import androidx.lifecycle.lifecycleScope 14 | import com.google.android.material.color.MaterialColors 15 | import kotlinx.coroutines.launch 16 | import net.steamcrafted.materialiconlib.MaterialDrawableBuilder.IconValue 17 | import top.learningman.push.R 18 | import top.learningman.push.application.MainApplication 19 | import top.learningman.push.data.Repo 20 | import top.learningman.push.databinding.ActivityMainBinding 21 | import top.learningman.push.provider.AutoChannel 22 | import top.learningman.push.provider.Channel 23 | import top.learningman.push.utils.Network 24 | import top.learningman.push.utils.PermissionManager 25 | import com.google.android.material.R as MaterialR 26 | 27 | class MainActivity : AppCompatActivity() { 28 | 29 | private val repo by lazy { 30 | (application as MainApplication).repo 31 | } 32 | 33 | private lateinit var binding: ActivityMainBinding 34 | private lateinit var channel: Channel 35 | 36 | private val startSetting = 37 | registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { 38 | Log.d("MainActivity", "Setting Activity Result ${it.resultCode}") 39 | if ((it.resultCode and SettingsActivity.UPDATE_CHANNEL) == SettingsActivity.UPDATE_CHANNEL) { 40 | Log.d("MainActivity", "Update Channel") 41 | channel = AutoChannel.getInstance(this) 42 | } else if ((it.resultCode and SettingsActivity.UPDATE_USERNAME) == SettingsActivity.UPDATE_USERNAME) { 43 | Log.d("MainActivity", "Update User") 44 | } 45 | 46 | if (it.resultCode != 0) { 47 | refreshStatus() 48 | lifecycleScope.launch { 49 | Network.updateClient(repo.getUser()) 50 | } 51 | channel.setUserCallback(this, repo.getUser(), lifecycleScope) 52 | } 53 | } 54 | 55 | override fun onCreate(savedInstanceState: Bundle?) { 56 | Log.d("MainActivity", "onCreate") 57 | 58 | installSplashScreen() 59 | super.onCreate(savedInstanceState) 60 | channel = AutoChannel.getInstance(this) 61 | if (!PermissionManager(this).ok()) { 62 | startActivity(Intent(this, SetupActivity::class.java).apply { 63 | addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_TASK_ON_HOME) 64 | }) 65 | finish() 66 | return 67 | } 68 | 69 | binding = ActivityMainBinding.inflate(layoutInflater) 70 | setContentView(binding.root) 71 | 72 | binding.goSetting.setOnClickListener { 73 | startSetting.launch(Intent(this, SettingsActivity::class.java)) 74 | } 75 | 76 | binding.goHistory.setOnClickListener { 77 | if (status == RegStatus.SUCCESS) { 78 | startActivity(Intent(this, MessagesActivity::class.java)) 79 | } else { 80 | Log.w("MainActivity", "Not registered $status") 81 | Toast.makeText(this, "请输入正确用户名", Toast.LENGTH_SHORT).show() 82 | } 83 | } 84 | 85 | val userid = repo.getUser() 86 | 87 | if (userid == Repo.PREF_USER_DEFAULT) { 88 | setStatus(RegStatus.NOT_SET) 89 | } else { 90 | refreshStatus() 91 | } 92 | 93 | channel.init(this) 94 | } 95 | 96 | private fun refreshStatus() { 97 | setStatus(RegStatus.PENDING) 98 | 99 | lifecycleScope.launch { 100 | Network.check(repo.getUser()) 101 | .onSuccess { 102 | if (it) { 103 | setStatus(RegStatus.SUCCESS) 104 | } else { 105 | setStatus(RegStatus.USERID_FAILED) 106 | } 107 | } 108 | .onFailure { 109 | setStatus(RegStatus.NETWORK_FAILED) 110 | } 111 | } 112 | } 113 | 114 | private var status = RegStatus.NOT_SET 115 | 116 | @SuppressLint("ResourceType") 117 | private fun setStatus(status: RegStatus) { 118 | this.status = status 119 | 120 | data class StatusData( 121 | val success: Boolean, 122 | val text: String, 123 | val icon: IconValue 124 | ) 125 | 126 | val statusMap = mapOf( 127 | RegStatus.SUCCESS to StatusData( 128 | true, 129 | getString(R.string.userid_success), 130 | IconValue.CHECK 131 | ), 132 | RegStatus.PENDING to StatusData( 133 | false, 134 | getString(R.string.loading), 135 | IconValue.SYNC 136 | ), 137 | RegStatus.NETWORK_FAILED to StatusData( 138 | false, 139 | getString(R.string.connect_err), 140 | IconValue.SYNC_ALERT 141 | ), 142 | RegStatus.USERID_FAILED to StatusData( 143 | false, 144 | getString(R.string.userid_failed), 145 | IconValue.ACCOUNT_ALERT 146 | ), 147 | RegStatus.NOT_SET to StatusData( 148 | false, 149 | getString(R.string.not_set_userid_err), 150 | IconValue.ALERT_CIRCLE_OUTLINE 151 | ) 152 | ) 153 | runOnUiThread { 154 | val currentStatus = statusMap[status] ?: return@runOnUiThread 155 | binding.regStatusText.text = currentStatus.text 156 | if (currentStatus.success) { 157 | binding.channelStatusText.visibility = View.VISIBLE 158 | binding.channelStatusText.text = channel.name 159 | 160 | val colorSurfaceVariant = MaterialColors.getColor( 161 | this, 162 | MaterialR.attr.colorSurfaceVariant, 163 | MaterialR.color.m3_sys_color_light_surface_variant 164 | ) 165 | val colorOnSurfaceVariant = MaterialColors.getColor( 166 | this, 167 | MaterialR.attr.colorOnSurfaceVariant, 168 | MaterialR.color.m3_sys_color_light_on_surface_variant 169 | ) 170 | 171 | binding.cardView.setCardBackgroundColor(ColorStateList.valueOf(colorSurfaceVariant)) 172 | binding.channelStatusText.setTextColor(colorOnSurfaceVariant) 173 | binding.regStatusText.setTextColor(colorOnSurfaceVariant) 174 | binding.regStatusIcon.setColor(colorOnSurfaceVariant) 175 | } else { 176 | binding.channelStatusText.visibility = View.GONE 177 | binding.channelStatusText.visibility = View.GONE 178 | 179 | } 180 | binding.regStatusIcon.setIcon(currentStatus.icon) 181 | } 182 | } 183 | 184 | 185 | companion object { 186 | 187 | enum class RegStatus { 188 | SUCCESS, 189 | PENDING, 190 | NETWORK_FAILED, 191 | USERID_FAILED, 192 | NOT_SET 193 | } 194 | } 195 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/top/learningman/push/activity/MessagesActivity.kt: -------------------------------------------------------------------------------- 1 | package top.learningman.push.activity 2 | 3 | import android.os.Bundle 4 | import androidx.activity.viewModels 5 | import androidx.appcompat.app.AppCompatActivity 6 | import androidx.recyclerview.widget.DividerItemDecoration 7 | import androidx.recyclerview.widget.LinearLayoutManager 8 | import top.learningman.push.application.MainApplication 9 | import top.learningman.push.data.Repo 10 | import top.learningman.push.databinding.ActivityMessagesBinding 11 | import top.learningman.push.entity.MessageAdapter 12 | import top.learningman.push.viewModel.MessageViewModel 13 | import kotlin.concurrent.thread 14 | 15 | class MessagesActivity : AppCompatActivity() { 16 | private lateinit var mLayoutManager: LinearLayoutManager 17 | private lateinit var mAdapter: MessageAdapter 18 | private lateinit var binding: ActivityMessagesBinding 19 | 20 | private val mViewModel: MessageViewModel by viewModels() 21 | 22 | override fun onCreate(savedInstanceState: Bundle?) { 23 | super.onCreate(savedInstanceState) 24 | binding = ActivityMessagesBinding.inflate(layoutInflater) 25 | setContentView(binding.root) 26 | 27 | mLayoutManager = LinearLayoutManager(this) 28 | mAdapter = MessageAdapter(mViewModel) 29 | 30 | val dividerItemDecoration = DividerItemDecoration(this, mLayoutManager.orientation) 31 | 32 | binding.messagesList.layoutManager = mLayoutManager 33 | binding.messagesList.adapter = mAdapter 34 | binding.messagesList.addItemDecoration(dividerItemDecoration) 35 | 36 | mViewModel.message.observe(this) { 37 | mAdapter.submitList(it) 38 | } 39 | 40 | mViewModel.isError.observe(this) { 41 | binding.warnNoMessage.visibility = if (it) { 42 | binding.messagesList.visibility = android.view.View.GONE 43 | android.view.View.VISIBLE 44 | } else { 45 | binding.messagesList.visibility = android.view.View.VISIBLE 46 | android.view.View.GONE 47 | } 48 | } 49 | 50 | val userid = (application as MainApplication).repo.getUser() 51 | 52 | if (userid != Repo.PREF_USER_DEFAULT) { 53 | thread { mViewModel.loadMessages() } 54 | } 55 | } 56 | 57 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/top/learningman/push/activity/SettingsActivity.kt: -------------------------------------------------------------------------------- 1 | package top.learningman.push.activity 2 | 3 | import android.content.Intent 4 | import android.os.Bundle 5 | import android.util.Log 6 | import android.widget.TextView 7 | import android.widget.Toast 8 | import androidx.appcompat.app.AppCompatActivity 9 | import androidx.preference.EditTextPreference 10 | import androidx.preference.ListPreference 11 | import androidx.preference.Preference 12 | import androidx.preference.PreferenceFragmentCompat 13 | import com.microsoft.appcenter.crashes.Crashes 14 | import dev.zxilly.notify.sdk.Client 15 | import kotlinx.coroutines.runBlocking 16 | import top.learningman.push.BuildConfig 17 | import top.learningman.push.Constant 18 | import top.learningman.push.R 19 | import top.learningman.push.checkUpgrade 20 | import top.learningman.push.databinding.ActivitySettingsBinding 21 | import top.learningman.push.provider.AutoChannel 22 | import top.learningman.push.provider.Channel 23 | import top.learningman.push.provider.channels 24 | import top.learningman.push.provider.getChannel 25 | import java.util.* 26 | import kotlin.concurrent.thread 27 | 28 | class SettingsActivity : AppCompatActivity() { 29 | var pendingInitChannel: Channel? = null 30 | 31 | override fun onCreate(savedInstanceState: Bundle?) { 32 | super.onCreate(savedInstanceState) 33 | val binding = ActivitySettingsBinding.inflate(layoutInflater) 34 | setContentView(binding.root) 35 | 36 | if (savedInstanceState == null) { 37 | supportFragmentManager 38 | .beginTransaction() 39 | .replace(R.id.settings, SettingsFragment()) 40 | .commit() 41 | } 42 | } 43 | 44 | class SettingsFragment : PreferenceFragmentCompat() { 45 | override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { 46 | setPreferencesFromResource(R.xml.root_preferences, rootKey) 47 | 48 | findPreference(getString(R.string.pref_version_key))?.apply { 49 | setOnPreferenceClickListener { 50 | runCatching { 51 | checkUpgrade(requireActivity()) 52 | }.onFailure { 53 | Log.e("Upgrader", "Failed to check upgrade", it) 54 | Crashes.trackError(it) 55 | } 56 | true 57 | } 58 | 59 | val summaryView: TextView? = view?.findViewById(android.R.id.summary) 60 | if (summaryView != null) { 61 | summaryView.maxLines = 3 62 | summaryView.isSingleLine = false 63 | } 64 | 65 | fun timeStampToFormattedString(timeStamp: Int): String { 66 | return if (timeStamp == 0) { 67 | "未知" 68 | } else { 69 | Log.d("Settings", "timeStamp: $timeStamp") 70 | val date = Date((timeStamp.toLong() * 1000)) 71 | val format = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.CHINA) 72 | format.format(date) 73 | } 74 | } 75 | 76 | summary = "${BuildConfig.VERSION_NAME}\n"+ 77 | "${BuildConfig.VERSION_CODE}\n"+ 78 | timeStampToFormattedString(BuildConfig.VERSION_CODE) 79 | } 80 | 81 | findPreference(getString(R.string.pref_user_id_key))?.apply { 82 | setOnPreferenceChangeListener { _, newValue -> 83 | thread { 84 | runBlocking { 85 | val ret = Client.check(newValue as String, Constant.API_ENDPOINT) 86 | if (!ret) { 87 | Log.d("SettingsActivity", "User ID is invalid") 88 | val activity = runCatching { requireActivity() } 89 | activity.getOrNull()?.runOnUiThread { 90 | Toast.makeText(context, "不是有效的用户ID", Toast.LENGTH_SHORT).show() 91 | } 92 | } 93 | } 94 | } 95 | (requireActivity() as SettingsActivity).updateResult(UPDATE_USERNAME) 96 | true 97 | } 98 | } 99 | 100 | findPreference(getString(R.string.pref_channel_key))?.apply { 101 | entries = channels 102 | .filter { 103 | it.available(context) 104 | }.map { 105 | it.name 106 | }.toTypedArray() 107 | entryValues = entries 108 | if (value == null) { 109 | value = AutoChannel.getInstance(context).name 110 | } 111 | 112 | setOnPreferenceChangeListener { _, newValue -> 113 | val old = getChannel(value) 114 | val next = getChannel(newValue as String) 115 | if (old != null && next != null) { 116 | old.release(context) 117 | 118 | if (next.granted(context)) { 119 | next.init(context) 120 | } else { 121 | (requireActivity() as SettingsActivity).pendingInitChannel = next 122 | Toast.makeText( 123 | context, 124 | getString(R.string.pref_grant_permission_tip), 125 | Toast.LENGTH_LONG 126 | ).show() 127 | startActivity(Intent(context, SetupActivity::class.java).apply { 128 | action = SetupActivity.PERMISSION_GRANT_ACTION 129 | }) 130 | } 131 | AutoChannel.updateInstance(next) 132 | (requireActivity() as SettingsActivity).updateResult(UPDATE_CHANNEL) 133 | } else { 134 | Log.e("SettingsActivity", "Failed to update channel") 135 | if (old == null) { 136 | Log.e("SettingsActivity", "Old channel $value is null") 137 | } 138 | if (next == null) { 139 | Log.e("SettingsActivity", "New channel $newValue is null") 140 | } 141 | } 142 | (requireActivity() as SettingsActivity).updateResult(UPDATE_CHANNEL) 143 | true 144 | } 145 | } ?: run { 146 | Log.e("SettingsActivity", "Cannot find channel preference") 147 | } 148 | 149 | } 150 | } 151 | 152 | override fun onResume() { 153 | super.onResume() 154 | 155 | if (pendingInitChannel != null) { 156 | pendingInitChannel?.init(this) 157 | pendingInitChannel = null 158 | } 159 | } 160 | 161 | private var resultCode = 0 162 | private fun updateResult(result: Int) { 163 | resultCode = resultCode or result 164 | setResult(resultCode) 165 | } 166 | 167 | companion object { 168 | const val UPDATE_USERNAME = 1 shl 0 169 | const val UPDATE_CHANNEL = 1 shl 1 170 | } 171 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/top/learningman/push/activity/SetupActivity.kt: -------------------------------------------------------------------------------- 1 | package top.learningman.push.activity 2 | 3 | import android.content.Intent 4 | import android.os.Bundle 5 | import android.view.LayoutInflater 6 | import android.view.View 7 | import android.view.ViewGroup 8 | import androidx.appcompat.app.AppCompatActivity 9 | import androidx.compose.foundation.layout.Column 10 | import androidx.compose.foundation.layout.Spacer 11 | import androidx.compose.foundation.layout.height 12 | import androidx.compose.foundation.layout.padding 13 | import androidx.compose.material3.Divider 14 | import androidx.compose.material3.MaterialTheme 15 | import androidx.compose.material3.OutlinedButton 16 | import androidx.compose.material3.Text 17 | import androidx.compose.runtime.Composable 18 | import androidx.compose.runtime.mutableStateListOf 19 | import androidx.compose.ui.Modifier 20 | import androidx.compose.ui.graphics.Color 21 | import androidx.compose.ui.platform.ComposeView 22 | import androidx.compose.ui.platform.ViewCompositionStrategy 23 | import androidx.compose.ui.unit.dp 24 | import androidx.core.view.WindowCompat 25 | import androidx.core.view.WindowInsetsCompat 26 | import androidx.fragment.app.Fragment 27 | import androidx.lifecycle.Lifecycle 28 | import androidx.lifecycle.lifecycleScope 29 | import androidx.lifecycle.repeatOnLifecycle 30 | import androidx.viewpager2.adapter.FragmentStateAdapter 31 | import androidx.viewpager2.widget.ViewPager2 32 | import com.android.setupwizardlib.SetupWizardLayout 33 | import com.google.android.material.textview.MaterialTextView 34 | import kotlinx.coroutines.launch 35 | import top.learningman.push.data.Repo 36 | import top.learningman.push.databinding.ActivitySetupBinding 37 | import top.learningman.push.provider.AutoChannel 38 | import top.learningman.push.provider.Permission 39 | import top.learningman.push.provider.WebSocket 40 | import top.learningman.push.utils.PermissionManager 41 | import top.learningman.push.utils.setTextAnimation 42 | import com.android.setupwizardlib.R as SuwR 43 | 44 | class SetupActivity : AppCompatActivity() { 45 | 46 | private lateinit var binding: ActivitySetupBinding 47 | private lateinit var viewPager: ViewPager2 48 | private lateinit var setup: SetupWizardLayout 49 | private lateinit var adapter: Adapter 50 | private lateinit var title: MaterialTextView 51 | private var isGrantPermission = false 52 | 53 | override fun onCreate(savedInstanceState: Bundle?) { 54 | super.onCreate(savedInstanceState) 55 | 56 | if (intent.action == PERMISSION_GRANT_ACTION) { 57 | isGrantPermission = true 58 | } 59 | 60 | binding = ActivitySetupBinding.inflate(layoutInflater) 61 | setContentView(binding.root) 62 | 63 | setup = binding.setup 64 | title = binding.setup.findViewById(SuwR.id.suw_layout_title)!! 65 | 66 | 67 | with(window) { 68 | WindowCompat.setDecorFitsSystemWindows(this, false) 69 | } 70 | 71 | adapter = Adapter(this) 72 | 73 | viewPager = binding.viewPager 74 | viewPager.adapter = adapter 75 | 76 | viewPager.setPageTransformer { view, position -> 77 | view.apply { 78 | val pageWidth = width 79 | when { 80 | -1 <= position && position <= 1 -> { 81 | translationX = pageWidth * -position 82 | } 83 | } 84 | alpha = when { 85 | position < -1 -> { 86 | 0f 87 | } 88 | position <= 1 -> { 89 | 1 - kotlin.math.abs(position) 90 | } 91 | else -> { 92 | 0f 93 | } 94 | } 95 | } 96 | } 97 | viewPager.isUserInputEnabled = false 98 | viewPager.registerOnPageChangeCallback( 99 | object : ViewPager2.OnPageChangeCallback() { 100 | override fun onPageSelected(position: Int) { 101 | super.onPageSelected(position) 102 | if (position == adapter.pages.size - 1) { 103 | setup.navigationBar.nextButton.text = "完成" 104 | } 105 | 106 | val fragment = adapter.pages[position] 107 | if (fragment is FragmentWithTitle) { 108 | title.setTextAnimation(fragment.title) 109 | } 110 | } 111 | }) 112 | 113 | viewPager.offscreenPageLimit = 1 114 | if (isGrantPermission) { 115 | viewPager.setCurrentItem(adapter.itemCount - 1, false) 116 | } 117 | 118 | setup = binding.setup 119 | setup.navigationBar.backButton.visibility = View.GONE 120 | setup.navigationBar.nextButton.setOnClickListener { 121 | if (viewPager.currentItem == adapter.itemCount - 1) { 122 | if (isGrantPermission) { 123 | finish() 124 | } else { 125 | startActivity(Intent(this, MainActivity::class.java).apply { 126 | addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK) 127 | }) 128 | finish() 129 | } 130 | } else { 131 | viewPager.currentItem += 1 132 | } 133 | } 134 | } 135 | 136 | override fun onAttachedToWindow() { 137 | super.onAttachedToWindow() 138 | val statusBarHeight = WindowInsetsCompat.toWindowInsetsCompat( 139 | window.decorView.rootWindowInsets, 140 | window.decorView 141 | ).getInsets(WindowInsetsCompat.Type.statusBars()).top 142 | val navigationBarHeight = WindowInsetsCompat.toWindowInsetsCompat( 143 | window.decorView.rootWindowInsets, 144 | window.decorView 145 | ).getInsets(WindowInsetsCompat.Type.navigationBars()) 146 | 147 | title.setPadding( 148 | title.paddingLeft, 149 | statusBarHeight + title.paddingTop, 150 | title.paddingRight, 151 | title.paddingBottom 152 | ) 153 | 154 | setup.setPadding( 155 | 0, 156 | 0, 157 | 0, 158 | navigationBarHeight.bottom 159 | ) 160 | } 161 | 162 | private class Adapter(fa: SetupActivity) : 163 | FragmentStateAdapter(fa) { 164 | val pages: MutableList = mutableListOf() 165 | 166 | init { 167 | pages += WelcomeFragment() 168 | pages += CurrentFragment() 169 | pages += RequestPermissionFragment() 170 | } 171 | 172 | override fun getItemCount(): Int { 173 | return pages.size 174 | } 175 | 176 | override fun createFragment(position: Int): Fragment { 177 | return pages[position] 178 | } 179 | } 180 | 181 | fun setNextButtonEnabled(enabled: Boolean) { 182 | setup.navigationBar.nextButton.isEnabled = enabled 183 | } 184 | 185 | interface FragmentWithTitle { 186 | val title: String 187 | } 188 | 189 | class WelcomeFragment : Fragment(), FragmentWithTitle { 190 | override val title = "欢迎" 191 | 192 | override fun onCreateView( 193 | inflater: LayoutInflater, 194 | container: ViewGroup?, 195 | savedInstanceState: Bundle? 196 | ): View { 197 | return ComposeView(requireContext()).apply { 198 | setViewCompositionStrategy( 199 | ViewCompositionStrategy.DisposeOnLifecycleDestroyed( 200 | viewLifecycleOwner 201 | ) 202 | ) 203 | setContent { 204 | MaterialTheme { 205 | Column(modifier = Modifier.padding(40.dp)) { 206 | Text("欢迎使用 Notify,Notify 是一个通过推送服务来提醒你的应用。") 207 | Spacer(modifier = Modifier.height(20.dp)) 208 | Text("Notify 接受 POST/PUT 发送的请求,并将结果推送到你的设备上") 209 | Spacer(modifier = Modifier.height(20.dp)) 210 | Text("Notify 当前仅支持自动选择推送服务") 211 | Text("在您的设备支持多个推送服务时,Notify 将会自动选择一个。") 212 | Spacer(modifier = Modifier.height(20.dp)) 213 | Text("如果您的设备不支持任何推送服务,Notify 将会启用自身的长连接推送实现,但是这将会消耗更多的电量。") 214 | } 215 | } 216 | } 217 | } 218 | } 219 | } 220 | 221 | class CurrentFragment : Fragment(), FragmentWithTitle { 222 | override val title = "当前推送服务" 223 | 224 | override fun onCreateView( 225 | inflater: LayoutInflater, 226 | container: ViewGroup?, 227 | savedInstanceState: Bundle? 228 | ): View { 229 | val channel = AutoChannel.getInstance(requireContext()) 230 | with(Repo.getInstance(requireContext())) { 231 | setChannel(channel.name) 232 | } 233 | return ComposeView(requireContext()).apply { 234 | setViewCompositionStrategy( 235 | ViewCompositionStrategy.DisposeOnLifecycleDestroyed( 236 | viewLifecycleOwner 237 | ) 238 | ) 239 | setContent { 240 | MaterialTheme { 241 | Column(modifier = Modifier.padding(40.dp)) { 242 | Text("当前启用的推送服务是: ${channel.name}") 243 | 244 | if (AutoChannel.by(WebSocket)) { 245 | Spacer(modifier = Modifier.height(20.dp)) 246 | Text("当前推送实现会消耗额外的电量。", color = Color.Gray) 247 | } 248 | 249 | Spacer(modifier = Modifier.height(20.dp)) 250 | Text("接下来,请为 Notify 授予必要的权限。") 251 | } 252 | } 253 | } 254 | } 255 | } 256 | } 257 | 258 | class RequestPermissionFragment : Fragment(), 259 | FragmentWithTitle { 260 | override val title = "授权" 261 | private lateinit var manager: PermissionManager 262 | 263 | override fun onCreateView( 264 | inflater: LayoutInflater, 265 | container: ViewGroup?, 266 | savedInstanceState: Bundle? 267 | ): View { 268 | manager = PermissionManager(requireActivity()) 269 | val ps = manager.permissions.map { 270 | it to it.check(requireContext()) 271 | }.toTypedArray() 272 | val permissions = mutableStateListOf( 273 | *ps 274 | ) 275 | viewLifecycleOwner.lifecycleScope.launch { 276 | viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) { 277 | permissions.replaceAll { 278 | val check = it.first.check(requireContext()) 279 | if (check != it.second) { 280 | it.first to check 281 | } else { 282 | it 283 | } 284 | } 285 | } 286 | } 287 | 288 | return ComposeView(requireContext()).apply { 289 | setViewCompositionStrategy( 290 | ViewCompositionStrategy.DisposeOnLifecycleDestroyed(viewLifecycleOwner) 291 | ) 292 | setContent { 293 | MaterialTheme { 294 | Column(modifier = Modifier.padding(40.dp)) { 295 | Text(text = "应用需要以下权限以运行:") 296 | Spacer(modifier = Modifier.height(16.dp)) 297 | permissions.map { 298 | PermissionLayout(permission = it.first, pass = it.second) 299 | } 300 | } 301 | } 302 | } 303 | } 304 | } 305 | 306 | @Composable 307 | fun PermissionLayout(permission: Permission, pass: Boolean?) { 308 | Column { 309 | Text(text = permission.name, style = MaterialTheme.typography.headlineSmall) 310 | Spacer(modifier = Modifier.height(8.dp)) 311 | Text( 312 | text = permission.description, 313 | style = MaterialTheme.typography.bodyLarge, 314 | color = MaterialTheme.colorScheme.outline 315 | ) 316 | Spacer(modifier = Modifier.height(16.dp)) 317 | OutlinedButton( 318 | enabled = pass != true, 319 | onClick = { permission.grant(requireActivity()) }) { 320 | Text(text = if (pass == true) "已授权" else "授权") 321 | } 322 | Spacer(modifier = Modifier.height(16.dp)) 323 | Divider() 324 | Spacer(modifier = Modifier.height(16.dp)) 325 | } 326 | } 327 | 328 | 329 | override fun onResume() { 330 | super.onResume() 331 | view?.requestLayout() 332 | (requireActivity() as SetupActivity).setNextButtonEnabled(manager.ok()) 333 | } 334 | } 335 | 336 | @Suppress("DEPRECATION") 337 | @Deprecated("Deprecated in Java") 338 | override fun onBackPressed() { 339 | if (!isGrantPermission) { 340 | super.onBackPressed() 341 | } 342 | } 343 | 344 | companion object { 345 | const val PERMISSION_GRANT_ACTION = "permission_grant_action" 346 | } 347 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/top/learningman/push/activity/TranslucentActivity.kt: -------------------------------------------------------------------------------- 1 | package top.learningman.push.activity 2 | 3 | import android.app.Activity 4 | import android.os.Bundle 5 | import android.util.Log 6 | import android.widget.Toast 7 | import top.learningman.push.databinding.ActivityTranslucentBinding 8 | import top.learningman.push.utils.fromRFC3339 9 | import top.learningman.push.view.MessageDialog 10 | 11 | class TranslucentActivity : Activity() { 12 | override fun onCreate(savedInstanceState: Bundle?) { 13 | Log.e("TranslucentActivity", "onCreate") 14 | super.onCreate(savedInstanceState) 15 | val binding = ActivityTranslucentBinding.inflate(layoutInflater) 16 | setContentView(binding.root) 17 | 18 | val long = intent.getStringExtra(LONG_INTENT_KEY) 19 | val msgID = intent.getStringExtra(MSGID_INTENT_KEY) 20 | val title = intent.getStringExtra(TITLE_INTENT_KEY) 21 | val content = intent.getStringExtra(CONTENT_INTENT_KEY) 22 | val time = intent.getStringExtra(TIME_INTENT_KEY) 23 | 24 | if (long == null 25 | || msgID == null 26 | || title == null 27 | || content == null 28 | || time == null 29 | ) { 30 | Toast.makeText(this, "intent missing field", Toast.LENGTH_SHORT).show() 31 | finish() 32 | return 33 | } 34 | 35 | val message = MessageDialog.Message( 36 | title, 37 | content, 38 | long, 39 | time.fromRFC3339(), 40 | msgID 41 | ) 42 | MessageDialog.show(message, this) { 43 | finish() 44 | } 45 | } 46 | 47 | companion object { 48 | const val LONG_INTENT_KEY = "long" 49 | const val MSGID_INTENT_KEY = "msg_id" 50 | const val TITLE_INTENT_KEY = "title" 51 | const val CONTENT_INTENT_KEY = "content" 52 | const val TIME_INTENT_KEY = "created_at" 53 | } 54 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/top/learningman/push/application/MainApplication.kt: -------------------------------------------------------------------------------- 1 | package top.learningman.push.application 2 | 3 | import android.app.Application 4 | import android.util.Log 5 | import androidx.appcompat.app.AppCompatDelegate 6 | import com.google.android.material.color.DynamicColors 7 | import com.microsoft.appcenter.AppCenter 8 | import com.microsoft.appcenter.analytics.Analytics 9 | import com.microsoft.appcenter.crashes.Crashes 10 | import kotlinx.coroutines.CoroutineScope 11 | import kotlinx.coroutines.Dispatchers 12 | import kotlinx.coroutines.launch 13 | import top.learningman.push.Constant 14 | import top.learningman.push.checkerInit 15 | import top.learningman.push.data.Repo 16 | import top.learningman.push.utils.Network 17 | import kotlin.system.exitProcess 18 | 19 | class MainApplication : Application() { 20 | val repo by lazy { 21 | Repo.getInstance(this) 22 | } 23 | 24 | override fun onCreate() { 25 | super.onCreate() 26 | AppCenter.start( 27 | this, 28 | Constant.APP_CENTER_SECRET, 29 | Analytics::class.java, 30 | Crashes::class.java 31 | ) 32 | 33 | val oldHandler = Thread.getDefaultUncaughtExceptionHandler() 34 | 35 | Thread.setDefaultUncaughtExceptionHandler { thr, err -> 36 | Crashes.trackError(err) 37 | if (oldHandler != null) oldHandler.uncaughtException( 38 | thr, 39 | err 40 | ) 41 | else exitProcess(2) 42 | } 43 | 44 | DynamicColors.applyToActivitiesIfAvailable(this) 45 | 46 | checkerInit(this) 47 | 48 | // FIXME: support dark mode 49 | AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO) 50 | 51 | CoroutineScope(Dispatchers.IO).launch { 52 | Network.updateClient(repo.getUser()) 53 | Log.i("MainApplication", "client init") 54 | } 55 | } 56 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/top/learningman/push/data/Repo.kt: -------------------------------------------------------------------------------- 1 | package top.learningman.push.data 2 | 3 | import android.content.Context 4 | import android.content.SharedPreferences 5 | import androidx.preference.PreferenceManager 6 | import top.learningman.push.R 7 | import java.util.* 8 | 9 | class Repo(private val sharedPref: SharedPreferences) { 10 | 11 | fun getUser(): String { 12 | return sharedPref.getString(PREF_USER_KEY, PREF_USER_DEFAULT)!! 13 | } 14 | 15 | fun getChannel(): String? { 16 | return sharedPref.getString(PREF_CHANNEL_KEY, null) 17 | } 18 | 19 | fun setChannel(channel: String) { 20 | sharedPref.edit().putString(PREF_CHANNEL_KEY, channel).apply() 21 | } 22 | 23 | fun getDeviceID(): String { 24 | val current = sharedPref.getString(PREF_DEVICE_ID_KEY, null) 25 | return if (current == null) { 26 | val new = UUID.randomUUID().toString() 27 | sharedPref.edit().putString(PREF_DEVICE_ID_KEY, new).apply() 28 | new 29 | } else { 30 | current 31 | } 32 | } 33 | 34 | companion object { 35 | private var instance: Repo? = null 36 | 37 | const val PREF_USER_KEY = "user_id" 38 | const val PREF_USER_DEFAULT = "plNjqo1n" 39 | const val PREF_DEVICE_ID_KEY = "device_id" 40 | var PREF_CHANNEL_KEY = "" 41 | 42 | 43 | fun getInstance(context: Context): Repo { 44 | PREF_CHANNEL_KEY = context.getString(R.string.pref_channel_key) 45 | return synchronized(Repo::class.java) { 46 | instance ?: let { 47 | val sharedPref = PreferenceManager.getDefaultSharedPreferences(context) 48 | Repo(sharedPref).also { 49 | instance = it 50 | } 51 | } 52 | } 53 | } 54 | } 55 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/top/learningman/push/entity/Message.kt: -------------------------------------------------------------------------------- 1 | package top.learningman.push.entity 2 | 3 | data class Message( 4 | val id: String, 5 | val title: String, 6 | val content: String, 7 | val createdAt: String, 8 | val long: String, 9 | ) 10 | -------------------------------------------------------------------------------- /app/src/main/kotlin/top/learningman/push/entity/MessageAdapter.kt: -------------------------------------------------------------------------------- 1 | package top.learningman.push.entity 2 | 3 | import android.content.DialogInterface 4 | import android.graphics.Color 5 | import android.text.format.DateUtils 6 | import android.view.LayoutInflater 7 | import android.view.View 8 | import android.view.ViewGroup 9 | import androidx.recyclerview.widget.DiffUtil 10 | import androidx.recyclerview.widget.ListAdapter 11 | import androidx.recyclerview.widget.RecyclerView 12 | import top.learningman.push.R 13 | import top.learningman.push.databinding.TextRowItemBinding 14 | import top.learningman.push.utils.fromRFC3339 15 | import top.learningman.push.view.MessageDialog 16 | import top.learningman.push.viewModel.MessageViewModel 17 | 18 | class MessageAdapter(private val viewModel: MessageViewModel) : 19 | ListAdapter(WordsComparator()) { 20 | 21 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MessageHolder { 22 | return MessageHolder.create(parent, viewModel) 23 | } 24 | 25 | override fun onBindViewHolder(holder: MessageHolder, position: Int) { 26 | val current = getItem(position) 27 | holder.bind(current) 28 | } 29 | 30 | class MessageHolder(itemView: View, private val viewModel: MessageViewModel) : 31 | RecyclerView.ViewHolder(itemView) { 32 | private val binding = TextRowItemBinding.bind(itemView) 33 | private val messageItem = binding.rowItem 34 | private val messageItemTitleView = binding.rowItemTitle 35 | private val messageItemContentView = binding.rowItemContent 36 | private val messageItemTimeView = binding.rowItemTime 37 | 38 | fun bind(msg: Message) { 39 | messageItemTitleView.text = msg.title 40 | messageItemContentView.text = msg.content 41 | 42 | val relativeTime = 43 | msg.createdAt.let { DateUtils.getRelativeTimeSpanString(it.fromRFC3339().time) } 44 | messageItemTimeView.text = relativeTime 45 | 46 | val message = MessageDialog.Message( 47 | msg.title, 48 | msg.content, 49 | msg.long, 50 | msg.createdAt.fromRFC3339(), 51 | msg.id, 52 | ) 53 | 54 | val dialog = MessageDialog.show(message, itemView.context, false) { positive -> 55 | if (!positive) { 56 | viewModel.deleteMessage(msg.id) 57 | } 58 | } 59 | 60 | messageItem.setOnClickListener { 61 | dialog.show() 62 | val neuButton = dialog.getButton(DialogInterface.BUTTON_NEUTRAL) 63 | neuButton.setTextColor(Color.RED) 64 | } 65 | } 66 | 67 | companion object { 68 | fun create(parent: ViewGroup, viewModel: MessageViewModel): MessageHolder { 69 | val view: View = LayoutInflater.from(parent.context) 70 | .inflate(R.layout.text_row_item, parent, false) 71 | return MessageHolder(view, viewModel) 72 | } 73 | } 74 | } 75 | 76 | class WordsComparator : DiffUtil.ItemCallback() { 77 | override fun areItemsTheSame(oldItem: Message, newItem: Message): Boolean { 78 | return oldItem.id == newItem.id 79 | } 80 | 81 | override fun areContentsTheSame(oldItem: Message, newItem: Message): Boolean { 82 | return this.areItemsTheSame(oldItem, newItem) 83 | } 84 | } 85 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/top/learningman/push/provider/Channel.kt: -------------------------------------------------------------------------------- 1 | package top.learningman.push.provider 2 | 3 | import android.app.Activity 4 | import android.content.Context 5 | import kotlinx.coroutines.CoroutineScope 6 | import top.learningman.push.data.Repo 7 | 8 | interface Permission { 9 | val name: String 10 | val description: String 11 | 12 | fun check(context: Context): Boolean? 13 | fun grant(activity: Activity) 14 | } 15 | 16 | interface Channel { 17 | val name: String 18 | 19 | fun init(context: Context) 20 | fun release(context: Context) 21 | 22 | fun available(context: Context): Boolean { 23 | return true 24 | } 25 | 26 | fun granted(context: Context): Boolean { 27 | val permissions = permissions() 28 | return if (permissions.isEmpty()) { 29 | true 30 | } else { 31 | permissions.all { 32 | it.check(context) ?: true 33 | } 34 | } 35 | } 36 | 37 | fun permissions(): List { 38 | return emptyList() 39 | } 40 | 41 | fun setUserCallback(context: Context, userID: String) {} 42 | fun setUserCallback(context: Context, userID: String, scope: CoroutineScope) { 43 | setUserCallback(context, userID) 44 | } 45 | } 46 | 47 | val channels = arrayOf(FCM, WebSocket) 48 | 49 | fun getChannel(name: String): Channel? { 50 | return channels.firstOrNull { it.name == name } 51 | } 52 | 53 | class AutoChannel private constructor(channel: Channel) : Channel by channel { 54 | companion object { 55 | private var instance: Channel? = null 56 | fun by(chan: Channel): Boolean { 57 | if (instance == null) { 58 | return false 59 | } 60 | return instance!!.name == chan.name 61 | } 62 | 63 | fun updateInstance(chan: Channel) { 64 | instance = chan 65 | } 66 | 67 | fun getInstance(context: Context): Channel { 68 | return if (instance != null) { 69 | instance as Channel 70 | } else { 71 | var impl: Channel? = null 72 | val channelID = Repo.getInstance(context).getChannel() 73 | if (channelID != null) { 74 | impl = getChannel(channelID).takeIf { it != null && it.available(context) } 75 | } 76 | 77 | if (impl == null) { 78 | impl = when { 79 | FCM.available(context) -> FCM 80 | else -> WebSocket 81 | } 82 | } 83 | 84 | instance = AutoChannel(impl) 85 | instance as Channel 86 | } 87 | } 88 | } 89 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/top/learningman/push/provider/FCM.kt: -------------------------------------------------------------------------------- 1 | package top.learningman.push.provider 2 | 3 | import android.content.Context 4 | import android.util.Log 5 | import android.widget.Toast 6 | import com.google.android.gms.common.ConnectionResult 7 | import com.google.android.gms.common.GoogleApiAvailability 8 | import com.google.firebase.ktx.Firebase 9 | import com.google.firebase.messaging.FirebaseMessaging 10 | import com.google.firebase.messaging.ktx.messaging 11 | import com.microsoft.appcenter.crashes.Crashes 12 | import kotlinx.coroutines.CoroutineScope 13 | import kotlinx.coroutines.launch 14 | import top.learningman.push.data.Repo 15 | import top.learningman.push.utils.Network 16 | import dev.zxilly.notify.sdk.entity.Channel as NotifyChannel 17 | 18 | object FCM : Channel { 19 | override val name: String 20 | get() = "Firebase Cloud Messaging" 21 | 22 | override fun init(context: Context) { 23 | if (!Firebase.messaging.isAutoInitEnabled) { 24 | Firebase.messaging.isAutoInitEnabled = true 25 | } 26 | } 27 | 28 | override fun release(context: Context) { 29 | Firebase.messaging.isAutoInitEnabled = false 30 | } 31 | 32 | override fun available(context: Context): Boolean { 33 | val ga = GoogleApiAvailability.getInstance() 34 | return when (ga.isGooglePlayServicesAvailable(context)) { 35 | ConnectionResult.SUCCESS -> true 36 | else -> false 37 | } 38 | } 39 | 40 | override fun setUserCallback(context: Context, userID: String, scope: CoroutineScope) { 41 | val deviceID = Repo.getInstance(context).getDeviceID() 42 | FirebaseMessaging.getInstance().token.addOnCompleteListener { task -> 43 | if (task.isSuccessful) { 44 | val token = task.result 45 | scope.launch { 46 | Network.register(token, NotifyChannel.FCM, deviceID) 47 | .onSuccess { 48 | Toast.makeText(context, "FCM 注册成功", Toast.LENGTH_LONG).show() 49 | Log.i("FCM", "FCM 注册成功") 50 | } 51 | .onFailure { 52 | Toast.makeText(context, "FCM 注册失败", Toast.LENGTH_LONG).show() 53 | Log.e("Firebase", "FCM 注册失败", it) 54 | Crashes.trackError(it) 55 | } 56 | } 57 | } else { 58 | Toast.makeText(context, "FCM 注册失败", Toast.LENGTH_LONG).show() 59 | Log.e("Firebase", "token error: ${task.exception}") 60 | task.exception?.let { Crashes.trackError(it) } ?: run { 61 | Crashes.trackError(Throwable("Failed to get token from FCM")) 62 | } 63 | } 64 | } 65 | } 66 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/top/learningman/push/provider/WebSocket.kt: -------------------------------------------------------------------------------- 1 | package top.learningman.push.provider 2 | 3 | import android.annotation.SuppressLint 4 | import android.app.Activity 5 | import android.content.ComponentName 6 | import android.content.Context 7 | import android.content.Intent 8 | import android.content.pm.PackageManager 9 | import android.net.Uri 10 | import android.provider.Settings 11 | import android.util.Log 12 | import android.widget.Toast 13 | import com.microsoft.appcenter.crashes.Crashes 14 | import kotlinx.coroutines.CoroutineScope 15 | import kotlinx.coroutines.Dispatchers 16 | import kotlinx.coroutines.launch 17 | import top.learningman.push.data.Repo 18 | import top.learningman.push.service.ReceiverService 19 | import top.learningman.push.utils.Network 20 | import xyz.kumaraswamy.autostart.Autostart 21 | import xyz.kumaraswamy.autostart.Utils 22 | import java.util.* 23 | import dev.zxilly.notify.sdk.entity.Channel as NotifyChannel 24 | 25 | 26 | private const val ENABLED_NOTIFICATION_LISTENERS = "enabled_notification_listeners" 27 | 28 | 29 | object WebSocket : Channel { 30 | override val name: String 31 | get() = "WebSocket" 32 | 33 | override fun init(context: Context) { 34 | context.packageManager.setComponentEnabledSetting( 35 | ComponentName(context, ReceiverService::class.java), 36 | PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP 37 | ) 38 | Log.i("WebSocket", "WebSocket 初始化") 39 | CoroutineScope(Dispatchers.IO).launch { 40 | Network.register( 41 | "", 42 | NotifyChannel.WebSocket, 43 | Repo.getInstance(context).getDeviceID() 44 | ).onSuccess { 45 | Log.i("WebSocket", "WebSocket init 成功") 46 | }.onFailure { 47 | Log.e("WebSocket", "WebSocket init 失败", it) 48 | Crashes.trackError(it) 49 | } 50 | } 51 | } 52 | 53 | override fun release(context: Context) { 54 | context.packageManager.setComponentEnabledSetting( 55 | ComponentName(context, ReceiverService::class.java), 56 | PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP 57 | ) 58 | } 59 | 60 | override fun setUserCallback(context: Context, userID: String, scope: CoroutineScope) { 61 | context.startService(Intent(context, ReceiverService::class.java).apply { 62 | action = ReceiverService.Action.UPDATE.name 63 | putExtra(ReceiverService.INTENT_USERID_KEY, userID) 64 | }) 65 | scope.launch { 66 | val deviceID = Repo.getInstance(context).getDeviceID() 67 | Network.register("", NotifyChannel.WebSocket, deviceID) 68 | .onSuccess { 69 | Toast.makeText(context, "WebSocket 注册成功", Toast.LENGTH_LONG).show() 70 | Log.i("WebSocket", "WebSocket 注册成功") 71 | } 72 | .onFailure { 73 | Log.e("WebSocket", "WebSocket 注册失败", it) 74 | Crashes.trackError(it) 75 | } 76 | } 77 | } 78 | 79 | override fun permissions(): List { 80 | val permissions = mutableListOf() 81 | 82 | val notificationListenerPermission = object : Permission { 83 | override val name: String 84 | get() = "通知监听器" 85 | override val description: String 86 | get() = "Notify 需要通知监听器权限以保持后台运行,Notify 不会读取您的通知内容。" 87 | 88 | override fun check(context: Context): Boolean { 89 | val enabledNotificationListeners = 90 | Settings.Secure.getString( 91 | context.contentResolver, 92 | ENABLED_NOTIFICATION_LISTENERS 93 | ) 94 | val componentName = ComponentName(context, ReceiverService::class.java) 95 | return enabledNotificationListeners != null 96 | && enabledNotificationListeners.contains(componentName.flattenToString()) 97 | } 98 | 99 | override fun grant(activity: Activity) { 100 | val intent = Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS) 101 | activity.startActivity(intent) 102 | } 103 | } 104 | permissions.add(notificationListenerPermission) 105 | 106 | val batteryIgnorePermission = object : Permission { 107 | override val name: String 108 | get() = "忽略电池优化" 109 | override val description: String 110 | get() = "Notify 需要忽略电池优化权限以保持后台运行。" 111 | 112 | override fun check(context: Context): Boolean { 113 | val powerManager = 114 | context.getSystemService(Context.POWER_SERVICE) as android.os.PowerManager 115 | return powerManager.isIgnoringBatteryOptimizations(context.packageName) 116 | } 117 | 118 | @SuppressLint("BatteryLife") 119 | override fun grant(activity: Activity) { 120 | val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS) 121 | intent.data = Uri.parse("package:${activity.packageName}") 122 | activity.startActivity(intent) 123 | } 124 | } 125 | permissions.add(batteryIgnorePermission) 126 | 127 | if (Utils.isOnMiui()) { 128 | val miuiAutoStartPermission = object : Permission { 129 | override val name: String 130 | get() = "自启动" 131 | override val description: String 132 | get() = "Notify 需要自启动权限以保持后台运行。" 133 | 134 | override fun check(context: Context): Boolean? { 135 | return kotlin.runCatching { 136 | return Autostart.isAutoStartEnabled(context) 137 | }.getOrNull() 138 | } 139 | 140 | override fun grant(activity: Activity) { 141 | val intent = Intent() 142 | intent.component = ComponentName( 143 | "com.miui.securitycenter", 144 | "com.miui.permcenter.autostart.AutoStartManagementActivity" 145 | ) 146 | activity.startActivity(intent) 147 | } 148 | } 149 | 150 | permissions.add(miuiAutoStartPermission) 151 | } 152 | 153 | return permissions 154 | } 155 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/top/learningman/push/service/BootReceiver.kt: -------------------------------------------------------------------------------- 1 | package top.learningman.push.service 2 | 3 | import android.content.BroadcastReceiver 4 | import android.content.Context 5 | import android.content.Intent 6 | 7 | class BootReceiver : BroadcastReceiver() { 8 | override fun onReceive(context: Context, intent: Intent) { 9 | if (intent.action == Intent.ACTION_BOOT_COMPLETED) { 10 | val serviceIntent = Intent(context, ReceiverService::class.java) 11 | context.startService(serviceIntent) 12 | } 13 | } 14 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/top/learningman/push/service/ReceiverService.kt: -------------------------------------------------------------------------------- 1 | package top.learningman.push.service 2 | 3 | import android.content.ComponentName 4 | import android.content.Intent 5 | import android.service.notification.NotificationListenerService 6 | import android.util.Log 7 | import top.learningman.push.service.websocket.WebSocketSessionManager 8 | import java.util.* 9 | 10 | 11 | class ReceiverService : NotificationListenerService() { 12 | init { 13 | Log.d("ReceiverService", "ReceiverService init") 14 | } 15 | 16 | val id = UUID.randomUUID().toString() 17 | 18 | private val tag 19 | get() = "Recv-${id.substring(0, 8)}" 20 | 21 | override fun onCreate() { 22 | super.onCreate() 23 | if (manager == null) { 24 | manager = WebSocketSessionManager(this) 25 | } 26 | manager?.setServiceID(id) 27 | Log.i(tag, "ReceiverService $id create") 28 | 29 | } 30 | 31 | override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { 32 | Log.d(tag, "onStartCommand executed with startId: $startId") 33 | if (intent != null) { 34 | Log.d(tag, "using an intent with action ${intent.action}") 35 | when (intent.action) { 36 | Action.UPDATE.name -> { 37 | val nextUserID = 38 | intent.getStringExtra(INTENT_USERID_KEY) 39 | if (!nextUserID.isNullOrEmpty()) { 40 | manager?.updateUserID(nextUserID) 41 | } 42 | } 43 | else -> { 44 | Log.d("ReceiverService", "Unknown action ${intent.action}") 45 | manager?.tryResume() 46 | } 47 | } 48 | } else { 49 | Log.d(tag, "with a null intent. It has been probably restarted by the system.") 50 | manager?.tryResume() 51 | } 52 | return START_STICKY 53 | } 54 | 55 | override fun onListenerDisconnected() { 56 | super.onListenerDisconnected() 57 | Log.d(tag, "onListenerDisconnected in $id") 58 | requestRebind( 59 | ComponentName( 60 | applicationContext, 61 | NotificationListenerService::class.java 62 | ) 63 | ) 64 | manager?.tryResume() 65 | } 66 | 67 | override fun onListenerConnected() { 68 | super.onListenerConnected() 69 | Log.d(tag, "onListenerConnected") 70 | manager?.tryResume() 71 | } 72 | 73 | override fun onDestroy() { 74 | super.onDestroy() 75 | 76 | Log.i(tag, "ReceiverService $id destroyed") 77 | } 78 | 79 | 80 | enum class Action { 81 | UPDATE 82 | } 83 | 84 | companion object { 85 | const val INTENT_USERID_KEY = "nextUserID" 86 | var manager: WebSocketSessionManager? = null 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /app/src/main/kotlin/top/learningman/push/service/Utils.kt: -------------------------------------------------------------------------------- 1 | package top.learningman.push.service 2 | 3 | import android.Manifest 4 | import android.app.NotificationChannel 5 | import android.app.NotificationManager 6 | import android.app.PendingIntent 7 | import android.content.Context 8 | import android.content.Intent 9 | import android.content.pm.PackageManager 10 | import android.util.Log 11 | import android.widget.Toast 12 | import androidx.core.app.ActivityCompat 13 | import androidx.core.app.NotificationCompat 14 | import androidx.core.app.NotificationManagerCompat 15 | import com.microsoft.appcenter.crashes.Crashes 16 | import top.learningman.push.R 17 | import top.learningman.push.activity.TranslucentActivity 18 | import top.learningman.push.entity.Message 19 | import top.learningman.push.utils.fromRFC3339Nano 20 | import top.learningman.push.utils.toRFC3339 21 | import kotlin.random.Random 22 | 23 | object Utils { 24 | fun notifyMessage(context: Context, message: Message) { 25 | val notificationManager = NotificationManagerCompat.from(context) 26 | val notifyChannel = NotificationChannel( 27 | NOTIFICATION_CHANNEL_ID, 28 | NOTIFICATION_CHANNEL_NAME, 29 | NotificationManager.IMPORTANCE_DEFAULT 30 | ) 31 | notificationManager.createNotificationChannel(notifyChannel) 32 | 33 | val intent = Intent(context, TranslucentActivity::class.java).apply { 34 | putExtra( 35 | TranslucentActivity.TIME_INTENT_KEY, 36 | message.createdAt.fromRFC3339Nano().toRFC3339() 37 | ) 38 | putExtra(TranslucentActivity.CONTENT_INTENT_KEY, message.content) 39 | putExtra(TranslucentActivity.TITLE_INTENT_KEY, message.title) 40 | putExtra(TranslucentActivity.MSGID_INTENT_KEY, message.id) 41 | putExtra(TranslucentActivity.LONG_INTENT_KEY, message.long) 42 | } 43 | 44 | val pendingIntent = PendingIntent.getActivity( 45 | context, 46 | 0, 47 | intent, 48 | PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE 49 | ) 50 | val notification = NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID) 51 | .setContentTitle(message.title) 52 | .setContentText(message.content) 53 | .setContentIntent(pendingIntent) 54 | .setSmallIcon(R.drawable.ic_message) 55 | .setAutoCancel(true) 56 | .build() 57 | 58 | if (ActivityCompat.checkSelfPermission( 59 | context, 60 | Manifest.permission.POST_NOTIFICATIONS 61 | ) != PackageManager.PERMISSION_GRANTED 62 | ) { 63 | Log.e("Push", "No permission to post notification") 64 | Crashes.trackError( 65 | Throwable( 66 | "No permission to post notification" 67 | ) 68 | ) 69 | Toast.makeText(context, "No permission to post notification", Toast.LENGTH_SHORT).show() 70 | return 71 | } 72 | Log.i("Push", "Notification posted") 73 | notificationManager.notify(Random.nextInt(), notification) 74 | } 75 | 76 | private const val NOTIFICATION_CHANNEL_ID = "ReceiverService" 77 | private const val NOTIFICATION_CHANNEL_NAME = "Normal Message" 78 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/top/learningman/push/service/websocket/WebSocketSessionManager.kt: -------------------------------------------------------------------------------- 1 | package top.learningman.push.service.websocket 2 | 3 | import android.content.Context 4 | import android.net.ConnectivityManager 5 | import android.util.Log 6 | import com.microsoft.appcenter.crashes.Crashes 7 | import io.ktor.client.* 8 | import io.ktor.client.engine.okhttp.* 9 | import io.ktor.client.plugins.* 10 | import io.ktor.client.plugins.websocket.* 11 | import io.ktor.client.request.* 12 | import io.ktor.http.* 13 | import io.ktor.websocket.* 14 | import kotlinx.coroutines.* 15 | import kotlinx.coroutines.channels.* 16 | import kotlinx.coroutines.selects.select 17 | import kotlinx.serialization.json.Json 18 | import okhttp3.OkHttpClient 19 | import top.learningman.push.Constant 20 | import top.learningman.push.data.Repo 21 | import dev.zxilly.notify.sdk.entity.Message as SDKMessage 22 | import top.learningman.push.entity.Message 23 | import top.learningman.push.service.ReceiverService 24 | import top.learningman.push.service.Utils.notifyMessage 25 | import java.net.ProtocolException 26 | import java.util.* 27 | import java.util.concurrent.TimeUnit 28 | import java.util.concurrent.atomic.AtomicInteger 29 | 30 | @OptIn(DelicateCoroutinesApi::class, ExperimentalCoroutinesApi::class) 31 | class WebSocketSessionManager(service: ReceiverService) : 32 | CoroutineScope by CoroutineScope(context = newSingleThreadContext("WebsocketSessionManager") + SupervisorJob()) { 33 | 34 | private var serviceID = service.id.substring(0, 8) 35 | private val applicationContext = service.applicationContext 36 | 37 | fun setServiceID(id: String) { 38 | serviceID = id.substring(0, 8) 39 | Log.d("WebSocketSessionManager", "Session controlled by $id") 40 | } 41 | 42 | private val tag 43 | get() = "Recv-$serviceID-Mgr" 44 | 45 | private val repo by lazy { Repo.getInstance(applicationContext) } 46 | private var currentUserID = repo.getUser() 47 | private val client by lazy { 48 | HttpClient(OkHttp) { 49 | install(WebSockets) 50 | install(HttpRequestRetry) 51 | engine { 52 | preconfigured = OkHttpClient.Builder() 53 | .pingInterval(30, TimeUnit.SECONDS) 54 | .build() 55 | } 56 | } 57 | } 58 | 59 | private class ManualCloseException : Exception("Manual Close Session") 60 | 61 | private var errorChannel = Channel(Channel.RENDEZVOUS) 62 | private val connectivityManager = 63 | applicationContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager 64 | private var status = AtomicInteger(Status.WAIT_START) 65 | private fun currentStatusString() = Status.valueOf(status.get()) 66 | 67 | private var retryLimit = 32 68 | 69 | 70 | object Status { 71 | const val WAIT_START = 0 72 | const val CONNECTING = 1 73 | const val RUNNING = 2 74 | const val WAIT_RECONNECT = 3 75 | const val NETWORK_LOST = 4 76 | const val INVALID = 5 77 | const val STOP = 6 78 | 79 | fun valueOf(value: Int): String { 80 | return when (value) { 81 | WAIT_START -> "WAIT_START" 82 | CONNECTING -> "CONNECTING" 83 | RUNNING -> "RUNNING" 84 | WAIT_RECONNECT -> "WAIT_RECONNECT" 85 | NETWORK_LOST -> "NETWORK_LOST" 86 | INVALID -> "INVALID" 87 | STOP -> "STOP" 88 | else -> "UNKNOWN" 89 | } 90 | } 91 | } 92 | 93 | private val networkCallback = object : ConnectivityManager.NetworkCallback() { 94 | override fun onAvailable(network: android.net.Network) { 95 | super.onAvailable(network) 96 | if (status.compareAndSet(Status.NETWORK_LOST, Status.WAIT_RECONNECT)) { 97 | Log.d(tag, "network available, try start websocket") 98 | Log.d(tag, "resume from network lost") 99 | tryResume() 100 | } else { 101 | Log.d(tag, "network available, but not in network lost status") 102 | } 103 | } 104 | 105 | override fun onLost(network: android.net.Network) { 106 | super.onLost(network) 107 | Log.d(tag, "network lost, stop websocket") 108 | if (status.get() == Status.STOP) { 109 | return 110 | } 111 | tryCancelJob("network lost") 112 | } 113 | } 114 | 115 | init { 116 | connectivityManager.registerDefaultNetworkCallback(networkCallback) 117 | } 118 | 119 | fun stop() { 120 | status.set(Status.STOP) 121 | 122 | tryCancelJob("stop", true) 123 | cancel(CancellationException("websocket manager destroyed.")) 124 | 125 | connectivityManager.unregisterNetworkCallback(networkCallback) 126 | } 127 | 128 | @OptIn(ExperimentalCoroutinesApi::class) 129 | fun tryCancelJob(reason: String = "unknown", elegant: Boolean = false) { 130 | Log.i(tag, "tryCancelJob reason: $reason") 131 | coroutineContext.ensureActive() 132 | if (elegant) { 133 | if (!errorChannel.isClosedForSend && !errorChannel.isClosedForReceive) { 134 | errorChannel.trySend(ManualCloseException()) 135 | } 136 | } else { 137 | coroutineContext.cancelChildren() 138 | } 139 | Log.d(tag, "tryCancelJob done reason: $reason elegant: $elegant") 140 | } 141 | 142 | private fun diagnose(msg: String = "") { 143 | Log.d(tag, "current thread ${Thread.currentThread().id}") 144 | if (msg.isNotEmpty()) { 145 | Log.d(tag, msg) 146 | } 147 | } 148 | 149 | fun tryResume() { 150 | Log.d(tag, "tryResume with status ${currentStatusString()} at ${Date()}") 151 | if (status.compareAndSet(Status.WAIT_START, Status.CONNECTING) || 152 | status.compareAndSet(Status.WAIT_RECONNECT, Status.CONNECTING) 153 | ) { 154 | connect() 155 | } else { 156 | Log.d( 157 | tag, 158 | "tryResume: not start websocket from status ${currentStatusString()}" 159 | ) 160 | } 161 | } 162 | 163 | fun updateUserID(nextUserID: String) { 164 | if (currentUserID != nextUserID) { 165 | currentUserID = nextUserID 166 | tryCancelJob("user changed", true) 167 | if (status.compareAndSet(Status.RUNNING, Status.WAIT_RECONNECT)) { 168 | launch { 169 | delay(1000) 170 | tryResume() 171 | } 172 | } 173 | } else { 174 | tryResume() 175 | } 176 | } 177 | 178 | private fun recover() { 179 | Log.i(tag, "recover at ${Date()}") 180 | if (status.compareAndSet(Status.WAIT_RECONNECT, Status.CONNECTING)) { 181 | if (retryLimit-- > 0) { 182 | tryCancelJob("recover") 183 | launch { 184 | delay(2000) 185 | connect() 186 | } 187 | } else { 188 | Log.e(tag, "retry limit reached, stop websocket") 189 | stop() 190 | } 191 | } else { 192 | Log.d(tag, "recover: not start websocket from status ${status.get()}") 193 | } 194 | } 195 | 196 | private fun connect() { 197 | diagnose() 198 | 199 | fun restart() { 200 | if (status.compareAndSet(Status.RUNNING, Status.WAIT_RECONNECT)) { 201 | recover() 202 | } else { 203 | Log.d(tag, "connect: not recover from status ${currentStatusString()}") 204 | } 205 | } 206 | 207 | if (status.get() == Status.INVALID) { 208 | Log.d(tag, "status is invalid, should not connect") 209 | return 210 | } 211 | 212 | if (!status.compareAndSet(Status.CONNECTING, Status.RUNNING)) { 213 | Log.d( 214 | tag, 215 | "connect: not connecting status,is ${currentStatusString()}" 216 | ) 217 | return 218 | } else { 219 | Log.d(tag, "connect: start websocket from CONNECTING") 220 | } 221 | 222 | launch { 223 | errorChannel = Channel(Channel.RENDEZVOUS) 224 | runCatching { 225 | client.webSocket({ 226 | url { 227 | takeFrom(Constant.API_WS_ENDPOINT) 228 | appendEncodedPathSegments(currentUserID, "host", "conn") 229 | } 230 | header("X-Device-ID", repo.getDeviceID()) 231 | }) { 232 | retryLimit = 32 233 | while (this@launch.isActive) { 234 | val frame = select> { 235 | incoming.onReceive { Result.success(it) } 236 | errorChannel.onReceive { Result.failure(it) } 237 | } 238 | frame.fold({ 239 | handleFrame(it) 240 | }, { 241 | when (it) { 242 | is ClosedReceiveChannelException -> { 243 | Log.d(tag, "websocket closed", it) 244 | restart() 245 | } 246 | is CancellationException -> { 247 | Log.d(tag, "websocket cancelled") 248 | restart() 249 | } 250 | is ManualCloseException -> { 251 | Log.d(tag, "websocket closed manually") 252 | close( 253 | CloseReason( 254 | CloseReason.Codes.GOING_AWAY, 255 | "manually close" 256 | ) 257 | ) 258 | restart() 259 | } 260 | else -> { 261 | Log.e(tag, "websocket unexpected error", it) 262 | restart() 263 | } 264 | } 265 | return@webSocket 266 | }) 267 | 268 | } 269 | } 270 | }.onFailure { 271 | Log.e(tag, "websocket error", it) 272 | if (it is ProtocolException) { 273 | if (it.message?.contains("401") == true) { 274 | Log.i(tag, "seems userid not valid") 275 | status.set(Status.INVALID) 276 | return@onFailure 277 | } 278 | } 279 | if (it is CancellationException) { 280 | return@onFailure 281 | } 282 | restart() 283 | } 284 | } 285 | } 286 | 287 | private fun handleFrame(frame: Frame) { 288 | when (frame) { 289 | is Frame.Text -> { 290 | val message = frame.readText() 291 | Log.d(tag, "handleFrame: $message") 292 | runCatching { 293 | Json.decodeFromString( 294 | SDKMessage.serializer(), 295 | message 296 | ) 297 | }.fold({ 298 | Log.d(tag, "prepare to send notification") 299 | val notificationMessage = Message( 300 | id = it.id, 301 | title = it.title, 302 | content = it.content, 303 | createdAt = it.createdAt, 304 | long = it.long 305 | ) 306 | notifyMessage(notificationMessage, from = serviceID) 307 | }, { 308 | Log.e(tag, "Error parsing message", it) 309 | Crashes.trackError(it) 310 | }) 311 | } 312 | else -> { 313 | Log.e(tag, "Received unexpected frame: ${frame.frameType.name}") 314 | } 315 | } 316 | } 317 | 318 | private fun notifyMessage(message: Message, from: String = "anonymous") { 319 | Log.d(tag, "notifyMessage: $message from $from") 320 | notifyMessage(applicationContext, message) 321 | } 322 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/top/learningman/push/utils/Extensions.kt: -------------------------------------------------------------------------------- 1 | package top.learningman.push.utils 2 | 3 | import android.icu.text.SimpleDateFormat 4 | import android.util.Log 5 | import android.view.View 6 | import android.widget.TextView 7 | import java.util.* 8 | 9 | fun TextView.setTextAnimation( 10 | text: String, 11 | duration: Long = 300, 12 | completion: (() -> Unit)? = null 13 | ) { 14 | Log.d("Animation", "setTextAnimation") 15 | fadOutAnimation(duration) { 16 | this.text = text 17 | fadInAnimation(duration) { 18 | completion?.let { 19 | it() 20 | } 21 | } 22 | } 23 | } 24 | 25 | fun View.fadOutAnimation( 26 | duration: Long = 300, 27 | visibility: Int = View.INVISIBLE, 28 | completion: (() -> Unit)? = null 29 | ) { 30 | animate() 31 | .alpha(0f) 32 | .setDuration(duration) 33 | .withEndAction { 34 | this.visibility = visibility 35 | completion?.let { 36 | it() 37 | } 38 | } 39 | } 40 | 41 | fun View.fadInAnimation(duration: Long = 300, completion: (() -> Unit)? = null) { 42 | alpha = 0f 43 | visibility = View.VISIBLE 44 | animate() 45 | .alpha(1f) 46 | .setDuration(duration) 47 | .withEndAction { 48 | completion?.let { 49 | it() 50 | } 51 | } 52 | } 53 | 54 | fun String.fromRFC3339(): Date { 55 | val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssXXX", Locale.getDefault()) 56 | return sdf.parse(this) 57 | } 58 | 59 | fun Date.toRFC3339(): String { 60 | val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssXXX", Locale.getDefault()) 61 | return sdf.format(this) 62 | } 63 | 64 | fun String.fromRFC3339Nano(): Date { 65 | val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSSSSSSSXXX", Locale.getDefault()) 66 | return sdf.parse(this) 67 | } 68 | 69 | fun Date.toRFC3339Nano(): String { 70 | val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSSSSSSSXXX", Locale.getDefault()) 71 | return sdf.format(this) 72 | } 73 | -------------------------------------------------------------------------------- /app/src/main/kotlin/top/learningman/push/utils/Markwon.kt: -------------------------------------------------------------------------------- 1 | package top.learningman.push.utils 2 | 3 | import android.content.Context 4 | import android.widget.Toast 5 | import androidx.browser.customtabs.CustomTabsIntent 6 | import androidx.core.net.toUri 7 | import io.noties.markwon.AbstractMarkwonPlugin 8 | import io.noties.markwon.Markwon 9 | import io.noties.markwon.MarkwonConfiguration 10 | import io.noties.markwon.ext.tables.TablePlugin 11 | import io.noties.markwon.html.HtmlPlugin 12 | import io.noties.markwon.image.ImagesPlugin 13 | import io.noties.markwon.image.network.OkHttpNetworkSchemeHandler 14 | import java.util.* 15 | 16 | 17 | object Markwon { 18 | private val cache = Collections.synchronizedMap(WeakHashMap()) 19 | 20 | fun getInstance(context: Context): Markwon { 21 | return cache.getOrPut(context) { 22 | return Markwon.builder(context) 23 | .usePlugin(ImagesPlugin.create { plugin -> 24 | plugin.addSchemeHandler( 25 | OkHttpNetworkSchemeHandler.create() 26 | ) 27 | }) 28 | .usePlugin(TablePlugin.create(context)) 29 | .usePlugin(HtmlPlugin.create()) 30 | .usePlugin(object : AbstractMarkwonPlugin() { 31 | override fun configureConfiguration(builder: MarkwonConfiguration.Builder) { 32 | super.configureConfiguration(builder) 33 | builder.linkResolver { _, link -> 34 | val intent = CustomTabsIntent.Builder() 35 | .setShowTitle(true) 36 | .build() 37 | val uri = runCatching { link.toUri() }.getOrElse { 38 | Toast.makeText(context, "无法打开链接", Toast.LENGTH_SHORT).show() 39 | return@linkResolver 40 | } 41 | runCatching { 42 | intent.launchUrl(context, uri) 43 | }.onFailure { 44 | Toast.makeText(context, "无法打开链接\n$uri", Toast.LENGTH_SHORT).show() 45 | } 46 | } 47 | } 48 | }) 49 | .build() 50 | } 51 | } 52 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/top/learningman/push/utils/Network.kt: -------------------------------------------------------------------------------- 1 | package top.learningman.push.utils 2 | 3 | import android.util.Log 4 | import dev.zxilly.notify.sdk.Client 5 | import dev.zxilly.notify.sdk.entity.Channel 6 | import kotlinx.coroutines.sync.Mutex 7 | import top.learningman.push.Constant 8 | import top.learningman.push.entity.Message 9 | 10 | object Network { 11 | private var client: Client? = null 12 | private val clientMutex = Mutex() 13 | 14 | private suspend fun sync(block: suspend () -> T): T { 15 | clientMutex.lock() 16 | val ret = block() 17 | clientMutex.unlock() 18 | return ret 19 | } 20 | 21 | suspend fun updateClient(userID: String) = sync { 22 | client = Client.create(userID, Constant.API_ENDPOINT) 23 | .onFailure { 24 | Log.e("Network", "Client create failed", it) 25 | } 26 | .getOrNull() 27 | } 28 | 29 | suspend fun requestDelete(msgID: String) = sync { 30 | return@sync client?.deleteMessage(msgID) 31 | ?: Result.failure(Exception("Client is null")) 32 | } 33 | 34 | suspend fun check(userID: String) = sync { 35 | Log.d("Network", "Checking userID: $userID") 36 | runCatching { 37 | return@sync Result.success(Client.check(userID, Constant.API_ENDPOINT)) 38 | }.onFailure { 39 | return@sync Result.failure(it) 40 | } 41 | return@sync Result.failure(Exception("Unknown error")) 42 | } 43 | 44 | suspend fun register( 45 | token: String, 46 | channel: Channel, 47 | deviceID: String 48 | ): Result = sync { 49 | return@sync client?.createDevice(channel, token, deviceID) 50 | ?: Result.failure(Exception("Client is null")) 51 | } 52 | 53 | suspend fun fetchMessage(): Result> = sync { 54 | client?.getMessages()?.onFailure { 55 | return@sync Result.failure(it) 56 | }?.getOrNull()?.let { ret -> 57 | return@sync Result.success(ret.map { 58 | Message( 59 | id = it.id, 60 | title = it.title, 61 | content = it.content, 62 | createdAt = it.createdAt, 63 | long = it.long, 64 | ) 65 | }) 66 | } ?: Result.failure(Exception("Client is null")) 67 | 68 | } 69 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/top/learningman/push/utils/PermissionManager.kt: -------------------------------------------------------------------------------- 1 | package top.learningman.push.utils 2 | 3 | import android.Manifest 4 | import android.app.Activity 5 | import android.content.Context 6 | import android.content.Intent 7 | import android.content.pm.PackageManager 8 | import android.net.Uri 9 | import android.os.Build 10 | import android.provider.Settings 11 | import androidx.core.app.ActivityCompat 12 | import androidx.core.app.NotificationManagerCompat 13 | import androidx.core.content.ContextCompat 14 | import top.learningman.push.provider.AutoChannel 15 | import top.learningman.push.provider.Permission 16 | 17 | class PermissionManager(val activity: Activity) { 18 | private val _permissions = mutableListOf() 19 | val permissions: List = _permissions 20 | 21 | init { 22 | val overlayPermission = object : Permission { 23 | override val name: String 24 | get() = "悬浮窗" 25 | override val description: String 26 | get() = "悬浮窗权限用于从通知显示完整通知" 27 | 28 | override fun check(context: Context): Boolean { 29 | return Settings.canDrawOverlays(context) 30 | } 31 | 32 | override fun grant(activity: Activity) { 33 | val intent = Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION) 34 | intent.data = Uri.parse("package:${activity.packageName}") 35 | activity.startActivity(intent) 36 | } 37 | } 38 | _permissions.add(overlayPermission) 39 | 40 | val notificationPermission = object : Permission { 41 | override val name: String 42 | get() = "通知" 43 | override val description: String 44 | get() = "通知权限用于发送通知" 45 | 46 | override fun check(context: Context): Boolean { 47 | return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { 48 | NotificationManagerCompat.from(context).areNotificationsEnabled() 49 | } else { 50 | ContextCompat.checkSelfPermission( 51 | context, 52 | Manifest.permission.POST_NOTIFICATIONS 53 | ) != PackageManager.PERMISSION_GRANTED 54 | } 55 | } 56 | 57 | override fun grant(activity: Activity) { 58 | if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { 59 | val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS) 60 | intent.putExtra(Settings.EXTRA_APP_PACKAGE, activity.packageName) 61 | activity.startActivity(intent) 62 | } else { 63 | ActivityCompat.requestPermissions( 64 | activity, 65 | arrayOf(Manifest.permission.POST_NOTIFICATIONS), 66 | 0 67 | ) 68 | } 69 | } 70 | } 71 | _permissions.add(notificationPermission) 72 | 73 | val channelPermissions = AutoChannel.getInstance(activity).permissions() 74 | _permissions.addAll(channelPermissions) 75 | } 76 | 77 | fun ok() = _permissions.all { it.check(activity) ?: true } 78 | 79 | companion object 80 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/top/learningman/push/view/MessageDialog.kt: -------------------------------------------------------------------------------- 1 | package top.learningman.push.view 2 | 3 | import android.annotation.SuppressLint 4 | import android.app.AlertDialog 5 | import android.content.Context 6 | import android.content.DialogInterface 7 | import android.graphics.Color 8 | import android.icu.text.SimpleDateFormat 9 | import android.text.method.LinkMovementMethod 10 | import android.util.Log 11 | import android.view.LayoutInflater 12 | import android.view.View 13 | import kotlinx.coroutines.runBlocking 14 | import top.learningman.push.databinding.MessageDialogBinding 15 | import top.learningman.push.utils.Markwon 16 | import top.learningman.push.utils.Network 17 | import java.util.* 18 | import kotlin.concurrent.thread 19 | 20 | object MessageDialog { 21 | data class Message( 22 | val title: String, 23 | val content: String, 24 | val long: String, 25 | val time: Date, 26 | val msgID: String 27 | ) 28 | 29 | @SuppressLint("InflateParams") 30 | fun show( 31 | msg: Message, 32 | context: Context, 33 | immediate: Boolean = true, 34 | cb: ((Boolean) -> Unit)? = null 35 | ): AlertDialog { 36 | val binding = MessageDialogBinding.inflate(LayoutInflater.from(context)) 37 | val dialogView = binding.root 38 | 39 | val dialogContent = binding.dialogContent 40 | val dialogLong = binding.dialogLong 41 | val dialogTime = binding.dialogTime 42 | 43 | dialogContent.text = msg.content 44 | if (msg.long.isNotBlank()) { 45 | val markwon = Markwon.getInstance(context) 46 | markwon.setMarkdown(dialogLong, msg.long) 47 | dialogLong.movementMethod = LinkMovementMethod.getInstance() 48 | dialogLong.visibility = View.VISIBLE 49 | } 50 | 51 | msg.time.let { 52 | dialogTime.text = 53 | SimpleDateFormat( 54 | "y年M月d日 HH:mm:ss", 55 | Locale.getDefault() 56 | ).format(it.time) 57 | } 58 | 59 | val dialog = AlertDialog.Builder(context) 60 | .setTitle(msg.title) 61 | .setView(dialogView) 62 | .setPositiveButton("确定") { dialog, _ -> 63 | dialog.dismiss() 64 | cb?.invoke(true) 65 | } 66 | .setNeutralButton("删除") { dialog, _ -> 67 | thread { 68 | runBlocking { 69 | Network.requestDelete(msg.msgID) 70 | .onFailure { 71 | Log.e("MessageDialog", "Delete error", it) 72 | } 73 | } 74 | } 75 | dialog.cancel() 76 | cb?.invoke(false) 77 | } 78 | .create() 79 | .apply { 80 | setCanceledOnTouchOutside(false) 81 | } 82 | if (!immediate) { 83 | return dialog 84 | } 85 | 86 | dialog.show() 87 | val neuButton = dialog.getButton(DialogInterface.BUTTON_NEUTRAL) 88 | neuButton.setTextColor(Color.RED) 89 | return dialog 90 | } 91 | } -------------------------------------------------------------------------------- /app/src/main/kotlin/top/learningman/push/viewModel/MessageViewModel.kt: -------------------------------------------------------------------------------- 1 | package top.learningman.push.viewModel 2 | 3 | import android.util.Log 4 | import androidx.lifecycle.LiveData 5 | import androidx.lifecycle.MutableLiveData 6 | import androidx.lifecycle.ViewModel 7 | import androidx.lifecycle.viewModelScope 8 | import kotlinx.coroutines.launch 9 | import top.learningman.push.entity.Message 10 | import top.learningman.push.utils.Network 11 | 12 | class MessageViewModel : ViewModel() { 13 | private var _message = MutableLiveData(emptyList()) 14 | val message: LiveData> = _message 15 | 16 | private var _isError = MutableLiveData(false) 17 | val isError: LiveData = _isError 18 | 19 | fun loadMessages() { 20 | viewModelScope.launch { 21 | Network.fetchMessage() 22 | .onSuccess { 23 | _message.postValue(it) 24 | _isError.postValue(false) 25 | } 26 | .onFailure { 27 | Log.e("MessageViewModel", it.message, it) 28 | _message.postValue(emptyList()) 29 | _isError.postValue(true) 30 | } 31 | } 32 | } 33 | 34 | fun deleteMessage(msg_id: String) { 35 | _message.value?.let { messageList -> 36 | val newList = messageList.filter { it.id != msg_id } 37 | _message.postValue(newList) 38 | } 39 | } 40 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 15 | 20 | 25 | 30 | 35 | 40 | 45 | 50 | 55 | 60 | 65 | 70 | 75 | 80 | 85 | 90 | 95 | 100 | 105 | 110 | 115 | 120 | 125 | 130 | 135 | 140 | 145 | 150 | 155 | 160 | 165 | 170 | 171 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 14 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_message.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 15 | 16 | 21 | 22 | 33 | 34 | 42 | 43 | 50 | 51 | 57 | 58 | 59 | 60 | 61 | 62 | 72 | 73 | 83 | 84 | 93 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_messages.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 22 | 23 | 34 | 35 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_settings.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 9 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_setup.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 14 | 15 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_translucent.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | -------------------------------------------------------------------------------- /app/src/main/res/layout/message_dialog.xml: -------------------------------------------------------------------------------- 1 | 8 | 9 | 14 | 15 | 24 | 25 | 30 | -------------------------------------------------------------------------------- /app/src/main/res/layout/text_row_item.xml: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | 22 | 23 | 33 | 34 | 43 | -------------------------------------------------------------------------------- /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/ZNotify/android/33735900f09f934e453813bc605a1eb53b1e46b9/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZNotify/android/33735900f09f934e453813bc605a1eb53b1e46b9/app/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZNotify/android/33735900f09f934e453813bc605a1eb53b1e46b9/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZNotify/android/33735900f09f934e453813bc605a1eb53b1e46b9/app/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZNotify/android/33735900f09f934e453813bc605a1eb53b1e46b9/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZNotify/android/33735900f09f934e453813bc605a1eb53b1e46b9/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZNotify/android/33735900f09f934e453813bc605a1eb53b1e46b9/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZNotify/android/33735900f09f934e453813bc605a1eb53b1e46b9/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZNotify/android/33735900f09f934e453813bc605a1eb53b1e46b9/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZNotify/android/33735900f09f934e453813bc605a1eb53b1e46b9/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #6750A4 4 | #6750A4 5 | #6750A4 6 | #410E0B 7 | #FFFFFF 8 | #F9DEDC 9 | #31111D 10 | #FFFFFF 11 | #FFD8E4 12 | #7D5260 13 | #000000 14 | #B3261E 15 | #79747E 16 | #1C1B1F 17 | #FFFBFE 18 | #F4EFF4 19 | #313033 20 | #49454F 21 | #1C1B1F 22 | #E7E0EC 23 | #FFFBFE 24 | #1D192B 25 | #FFFFFF 26 | #E8DEF8 27 | #625B71 28 | #D0BCFF 29 | #21005D 30 | #FFFFFF 31 | #EADDFF 32 | #6750A4 33 | #D0BCFF 34 | #D0BCFF 35 | #F2B8B5 36 | #601410 37 | #8C1D18 38 | #FFD8E4 39 | #492532 40 | #633B48 41 | #EFB8C8 42 | #000000 43 | #F2B8B5 44 | #938F99 45 | #E6E1E5 46 | #1C1B1F 47 | #313033 48 | #E6E1E5 49 | #CAC4D0 50 | #E6E1E5 51 | #49454F 52 | #1C1B1F 53 | #E8DEF8 54 | #332D41 55 | #4A4458 56 | #CCC2DC 57 | #6750A4 58 | #EADDFF 59 | #381E72 60 | #4F378B 61 | #D0BCFF 62 | -------------------------------------------------------------------------------- /app/src/main/res/values/dimens.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 50dp 4 | 10dp 5 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Notify 3 | 设置 4 | 推送消息 5 | 6 | 7 | 用户 ID 8 | Logo 9 | 设置 10 | 推送历史 11 | 服务器设置 12 | 加载中 13 | User ID 未设置 14 | User ID 无效 15 | 工作正常 16 | 无法连接服务器 17 | 注册成功 18 | 注册失败 19 | FCM 注册成功 20 | FCM 注册失败 21 | 其他 22 | 版本 23 | 24 | 权限需求 25 | 应用需要悬浮窗权限以正常工作,请在设置中开启 26 | 应用需要通知权限以正常工作 27 | 授权 28 | 拒绝 29 | 应用需要通知监听器权限保持运行 30 | 31 | 32 | 33 | 34 | 推送渠道 35 | 可用渠道 36 | channel 37 | user_id 38 | version 39 | 请为新渠道配置权限 40 | 用户 ID 无效 41 | 消息加载失败 42 | -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 38 | 39 | 43 | 44 | 48 | 49 | 52 | 53 | 57 | 58 | 62 | 63 | 70 | -------------------------------------------------------------------------------- /app/src/main/res/xml/root_preferences.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | 11 | 12 | 13 | 20 | 21 | 22 | 23 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /app/src/play/kotlin/top/learningman/push/Checker.kt: -------------------------------------------------------------------------------- 1 | package top.learningman.push 2 | 3 | import android.app.Activity 4 | import android.app.Application 5 | import android.content.Context 6 | import com.google.android.play.core.appupdate.AppUpdateManagerFactory 7 | import com.google.android.play.core.appupdate.AppUpdateOptions 8 | import com.google.android.play.core.install.model.AppUpdateType 9 | import com.google.android.play.core.install.model.UpdateAvailability 10 | 11 | internal fun checkerInit(app: Application) {} 12 | internal fun checkUpgrade(context: Context) { 13 | val appUpdateManager = AppUpdateManagerFactory.create(context) 14 | 15 | val appUpdateInfoTask = appUpdateManager.appUpdateInfo 16 | 17 | appUpdateInfoTask.addOnSuccessListener { appUpdateInfo -> 18 | if (appUpdateInfo.updateAvailability() == UpdateAvailability.UPDATE_AVAILABLE 19 | && appUpdateInfo.isUpdateTypeAllowed(AppUpdateType.IMMEDIATE) 20 | ) { 21 | appUpdateManager.startUpdateFlow( 22 | appUpdateInfo, 23 | context as Activity, 24 | AppUpdateOptions.defaultOptions(AppUpdateType.IMMEDIATE) 25 | ) 26 | } 27 | } 28 | } -------------------------------------------------------------------------------- /app/src/release/res/xml/network_security_config.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /buildSrc/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | `kotlin-dsl` 3 | } 4 | 5 | repositories { 6 | google() 7 | mavenCentral() 8 | } 9 | -------------------------------------------------------------------------------- /buildSrc/src/main/java/dev/zxilly/gradle/helper.kt: -------------------------------------------------------------------------------- 1 | package dev.zxilly.gradle 2 | 3 | import org.codehaus.groovy.runtime.ProcessGroovyMethods 4 | 5 | fun String.exec(): String { 6 | val str = ProcessGroovyMethods.getText(ProcessGroovyMethods.execute(this)) 7 | return str.trim() 8 | } -------------------------------------------------------------------------------- /buildSrc/src/main/java/dev/zxilly/gradle/setup.gradle.kts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZNotify/android/33735900f09f934e453813bc605a1eb53b1e46b9/buildSrc/src/main/java/dev/zxilly/gradle/setup.gradle.kts -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.jvmargs=-Dfile.encoding=UTF-8 2 | 3 | android.useAndroidX=true 4 | 5 | kotlin.code.style=official 6 | 7 | android.nonTransitiveRClass=true 8 | android.enableJetifier=false 9 | 10 | android.nonFinalResIds=false -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZNotify/android/33735900f09f934e453813bc605a1eb53b1e46b9/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | # 21 | # Gradle start up script for POSIX generated by Gradle. 22 | # 23 | # Important for running: 24 | # 25 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 26 | # noncompliant, but you have some other compliant shell such as ksh or 27 | # bash, then to run this script, type that shell name before the whole 28 | # command line, like: 29 | # 30 | # ksh Gradle 31 | # 32 | # Busybox and similar reduced shells will NOT work, because this script 33 | # requires all of these POSIX shell features: 34 | # * functions; 35 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 36 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 37 | # * compound commands having a testable exit status, especially «case»; 38 | # * various built-in commands including «command», «set», and «ulimit». 39 | # 40 | # Important for patching: 41 | # 42 | # (2) This script targets any POSIX shell, so it avoids extensions provided 43 | # by Bash, Ksh, etc; in particular arrays are avoided. 44 | # 45 | # The "traditional" practice of packing multiple parameters into a 46 | # space-separated string is a well documented source of bugs and security 47 | # problems, so this is (mostly) avoided, by progressively accumulating 48 | # options in "$@", and eventually passing that to Java. 49 | # 50 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 51 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 52 | # see the in-line comments for details. 53 | # 54 | # There are tweaks for specific operating systems such as AIX, CygWin, 55 | # Darwin, MinGW, and NonStop. 56 | # 57 | # (3) This script is generated from the Groovy template 58 | # https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 59 | # within the Gradle project. 60 | # 61 | # You can find Gradle at https://github.com/gradle/gradle/. 62 | # 63 | ############################################################################## 64 | 65 | # Attempt to set APP_HOME 66 | 67 | # Resolve links: $0 may be a link 68 | app_path=$0 69 | 70 | # Need this for daisy-chained symlinks. 71 | while 72 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 73 | [ -h "$app_path" ] 74 | do 75 | ls=$( ls -ld "$app_path" ) 76 | link=${ls#*' -> '} 77 | case $link in #( 78 | /*) app_path=$link ;; #( 79 | *) app_path=$APP_HOME$link ;; 80 | esac 81 | done 82 | 83 | # This is normally unused 84 | # shellcheck disable=SC2034 85 | APP_BASE_NAME=${0##*/} 86 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) 87 | APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit 88 | 89 | # Use the maximum available, or set MAX_FD != -1 to use that value. 90 | MAX_FD=maximum 91 | 92 | warn () { 93 | echo "$*" 94 | } >&2 95 | 96 | die () { 97 | echo 98 | echo "$*" 99 | echo 100 | exit 1 101 | } >&2 102 | 103 | # OS specific support (must be 'true' or 'false'). 104 | cygwin=false 105 | msys=false 106 | darwin=false 107 | nonstop=false 108 | case "$( uname )" in #( 109 | CYGWIN* ) cygwin=true ;; #( 110 | Darwin* ) darwin=true ;; #( 111 | MSYS* | MINGW* ) msys=true ;; #( 112 | NONSTOP* ) nonstop=true ;; 113 | esac 114 | 115 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 116 | 117 | 118 | # Determine the Java command to use to start the JVM. 119 | if [ -n "$JAVA_HOME" ] ; then 120 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 121 | # IBM's JDK on AIX uses strange locations for the executables 122 | JAVACMD=$JAVA_HOME/jre/sh/java 123 | else 124 | JAVACMD=$JAVA_HOME/bin/java 125 | fi 126 | if [ ! -x "$JAVACMD" ] ; then 127 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 128 | 129 | Please set the JAVA_HOME variable in your environment to match the 130 | location of your Java installation." 131 | fi 132 | else 133 | JAVACMD=java 134 | if ! command -v java >/dev/null 2>&1 135 | then 136 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 137 | 138 | Please set the JAVA_HOME variable in your environment to match the 139 | location of your Java installation." 140 | fi 141 | fi 142 | 143 | # Increase the maximum file descriptors if we can. 144 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 145 | case $MAX_FD in #( 146 | max*) 147 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 148 | # shellcheck disable=SC2039,SC3045 149 | MAX_FD=$( ulimit -H -n ) || 150 | warn "Could not query maximum file descriptor limit" 151 | esac 152 | case $MAX_FD in #( 153 | '' | soft) :;; #( 154 | *) 155 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 156 | # shellcheck disable=SC2039,SC3045 157 | ulimit -n "$MAX_FD" || 158 | warn "Could not set maximum file descriptor limit to $MAX_FD" 159 | esac 160 | fi 161 | 162 | # Collect all arguments for the java command, stacking in reverse order: 163 | # * args from the command line 164 | # * the main class name 165 | # * -classpath 166 | # * -D...appname settings 167 | # * --module-path (only if needed) 168 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 169 | 170 | # For Cygwin or MSYS, switch paths to Windows format before running java 171 | if "$cygwin" || "$msys" ; then 172 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 173 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 174 | 175 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 176 | 177 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 178 | for arg do 179 | if 180 | case $arg in #( 181 | -*) false ;; # don't mess with options #( 182 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 183 | [ -e "$t" ] ;; #( 184 | *) false ;; 185 | esac 186 | then 187 | arg=$( cygpath --path --ignore --mixed "$arg" ) 188 | fi 189 | # Roll the args list around exactly as many times as the number of 190 | # args, so each arg winds up back in the position where it started, but 191 | # possibly modified. 192 | # 193 | # NB: a `for` loop captures its iteration list before it begins, so 194 | # changing the positional parameters here affects neither the number of 195 | # iterations, nor the values presented in `arg`. 196 | shift # remove old arg 197 | set -- "$@" "$arg" # push replacement arg 198 | done 199 | fi 200 | 201 | 202 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 203 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 204 | 205 | # Collect all arguments for the java command: 206 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, 207 | # and any embedded shellness will be escaped. 208 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be 209 | # treated as '${Hostname}' itself on the command line. 210 | 211 | set -- \ 212 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 213 | -classpath "$CLASSPATH" \ 214 | org.gradle.wrapper.GradleWrapperMain \ 215 | "$@" 216 | 217 | # Stop when "xargs" is not available. 218 | if ! command -v xargs >/dev/null 2>&1 219 | then 220 | die "xargs is not available" 221 | fi 222 | 223 | # Use "xargs" to parse quoted args. 224 | # 225 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 226 | # 227 | # In Bash we could simply go: 228 | # 229 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 230 | # set -- "${ARGS[@]}" "$@" 231 | # 232 | # but POSIX shell has neither arrays nor command substitution, so instead we 233 | # post-process each arg (as a line of input to sed) to backslash-escape any 234 | # character that might be a shell metacharacter, then use eval to reverse 235 | # that process (while maintaining the separation between arguments), and wrap 236 | # the whole thing up as a single "set" statement. 237 | # 238 | # This will of course break if any of these variables contains a newline or 239 | # an unmatched quote. 240 | # 241 | 242 | eval "set -- $( 243 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 244 | xargs -n1 | 245 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 246 | tr '\n' ' ' 247 | )" '"$@"' 248 | 249 | exec "$JAVACMD" "$@" 250 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%"=="" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%"=="" set DIRNAME=. 29 | @rem This is normally unused 30 | set APP_BASE_NAME=%~n0 31 | set APP_HOME=%DIRNAME% 32 | 33 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 34 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 35 | 36 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 37 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 38 | 39 | @rem Find java.exe 40 | if defined JAVA_HOME goto findJavaFromJavaHome 41 | 42 | set JAVA_EXE=java.exe 43 | %JAVA_EXE% -version >NUL 2>&1 44 | if %ERRORLEVEL% equ 0 goto execute 45 | 46 | echo. 47 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 48 | echo. 49 | echo Please set the JAVA_HOME variable in your environment to match the 50 | echo location of your Java installation. 51 | 52 | goto fail 53 | 54 | :findJavaFromJavaHome 55 | set JAVA_HOME=%JAVA_HOME:"=% 56 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 57 | 58 | if exist "%JAVA_EXE%" goto execute 59 | 60 | echo. 61 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 62 | echo. 63 | echo Please set the JAVA_HOME variable in your environment to match the 64 | echo location of your Java installation. 65 | 66 | goto fail 67 | 68 | :execute 69 | @rem Setup the command line 70 | 71 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 72 | 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if %ERRORLEVEL% equ 0 goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | set EXIT_CODE=%ERRORLEVEL% 85 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 86 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 87 | exit /b %EXIT_CODE% 88 | 89 | :mainEnd 90 | if "%OS%"=="Windows_NT" endlocal 91 | 92 | :omega 93 | -------------------------------------------------------------------------------- /release.jks: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZNotify/android/33735900f09f934e453813bc605a1eb53b1e46b9/release.jks -------------------------------------------------------------------------------- /scripts/appcenter.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | RELEASE_NOTES="$(git log -1 --pretty=short)" 3 | 4 | app="zxilly/Notify" 5 | 6 | appcenter distribute release \ 7 | --app "$app" \ 8 | --group "public" \ 9 | --file "app/build/outputs/apk/appcenter/release/app-appcenter-release.apk" \ 10 | --release-notes "$RELEASE_NOTES" \ 11 | --token "$APPCENTER_TOKEN" 12 | 13 | artifacts=("appcenterRelease" "freeRelease" "githubRelease" "playRelease") 14 | 15 | for artifact in "${artifacts[@]}"; do 16 | appcenter crashes upload-mappings \ 17 | --app "$app" \ 18 | --version-code "$VERSION_CODE" \ 19 | --version-name "$VERSION_NAME" \ 20 | --mapping "app/build/outputs/mapping/$artifact/mapping.txt" \ 21 | --token "$APPCENTER_TOKEN" 22 | done -------------------------------------------------------------------------------- /scripts/release_check.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # get last git commit message 4 | git_commit_message=$(git log -1 --pretty=%B) 5 | 6 | if [[ $git_commit_message == *"[release:"* ]]; then 7 | echo "release=true" >> "$GITHUB_OUTPUT" 8 | echo "Should release" 9 | # get version from [release: x.x.x] 10 | version=$(echo "$git_commit_message" | grep -o "\[release:.*\]" | sed "s/\[release:\s*//g" | sed "s/\s*\]//g") 11 | if [[ $version =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then 12 | echo "version=$version" >> "$GITHUB_OUTPUT" 13 | echo "Version is $version" 14 | else 15 | echo "Version format is not correct" 16 | echo "Version format should be x.x.x, x is number" 17 | echo "But got $version" 18 | exit 1 19 | fi 20 | 21 | else 22 | echo "release=false" >> "$GITHUB_OUTPUT" 23 | echo "Should not release" 24 | fi 25 | -------------------------------------------------------------------------------- /scripts/whats_new.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | mkdir whatsNew 3 | echo "请参阅 https://github.com/ZNotify/android/releases/tag/$VERSION" > whatsNew/whatsnew-zh-CN 4 | echo "Please refer to https://github.com/ZNotify/android/releases/tag/$VERSION" > whatsNew/whatsnew-en 5 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | @file:Suppress("UnstableApiUsage") 2 | 3 | fun getProp(key: String): String? { 4 | val isCI = System.getenv("CI") == "true" 5 | return if (isCI) { 6 | System.getenv(key) 7 | } else { 8 | val localProperties = File(rootDir, "local.properties") 9 | if (localProperties.exists()) { 10 | java.util.Properties().apply { 11 | localProperties.inputStream().use { load(it) } 12 | }.getProperty(key) 13 | } else null 14 | } 15 | } 16 | 17 | pluginManagement { 18 | repositories { 19 | google() 20 | gradlePluginPortal() 21 | mavenCentral() 22 | } 23 | } 24 | 25 | dependencyResolutionManagement { 26 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) 27 | repositories { 28 | google() 29 | mavenCentral() 30 | maven { 31 | url = uri("https://jitpack.io") 32 | } 33 | maven { 34 | url = uri("https://androidx.dev/storage/compose-compiler/repository/") 35 | } 36 | maven { 37 | url = uri("https://maven.pkg.github.com/Zxilly/upgrader") 38 | credentials { 39 | username = getProp("GITHUB_USER") ?: "Zxilly" 40 | password = getProp("GITHUB_TOKEN") 41 | } 42 | } 43 | } 44 | } 45 | 46 | plugins { 47 | `gradle-enterprise` 48 | } 49 | 50 | gradleEnterprise { 51 | buildScan { 52 | termsOfServiceUrl = "https://gradle.com/terms-of-service" 53 | termsOfServiceAgree = "yes" 54 | publishAlwaysIf(System.getenv("GITHUB_ACTIONS") == "true") 55 | publishOnFailure() 56 | } 57 | } 58 | 59 | rootProject.name = "Notify" 60 | 61 | include("app") 62 | -------------------------------------------------------------------------------- /static/google-play-badge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZNotify/android/33735900f09f934e453813bc605a1eb53b1e46b9/static/google-play-badge.png --------------------------------------------------------------------------------