├── .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
--------------------------------------------------------------------------------