diff --git a/.github/workflows/test_ci.yml b/.github/workflows/test_ci.yml index 6d3ca45f6..601f4f270 100644 --- a/.github/workflows/test_ci.yml +++ b/.github/workflows/test_ci.yml @@ -15,21 +15,24 @@ jobs: steps: - uses: actions/checkout@v4 - - name: set up JDK 11 + - name: set up JDK 17 uses: actions/setup-java@v4 with: - java-version: '11' + java-version: '17' distribution: 'temurin' cache: gradle - name: Write key run: | - if [ ! -z "${{ secrets.SIGNING_KEY }}" ]; then - echo KEYSTORE_PASSWORD='${{ secrets.KEYSTORE_PASSWORD }}' >> local.properties - echo KEYSTORE_ALIAS='${{ secrets.KEYSTORE_ALIAS }}' >> local.properties - echo KEY_PASSWORD='${{ secrets.KEY_PASSWORD }}' >> local.properties + if [ ! -z "${{ secrets.MIPUSH_SIGNING_KEY }}" ]; then + echo HAS_SIGNING_KEY=true >> $GITHUB_ENV + echo KEYSTORE_PASSWORD='${{ secrets.MIPUSH_KEYSTORE_PASSWORD }}' >> local.properties + echo KEYSTORE_ALIAS='${{ secrets.MIPUSH_KEY_ALIAS }}' >> local.properties + echo KEY_PASSWORD='${{ secrets.MIPUSH_KEY_PASSWORD }}' >> local.properties echo KEY_LOCATE='../release.keystore' >> local.properties - echo ${{ secrets.SIGNING_KEY }} | base64 --decode > release.keystore + echo ${{ secrets.MIPUSH_SIGNING_KEY }} | base64 --decode > release.keystore + else + echo HAS_SIGNING_KEY=false >> $GITHUB_ENV fi - name: Git describe @@ -40,56 +43,69 @@ jobs: - name: Grant execute permission for gradlew run: chmod +x gradlew - - name: Build with Gradle - run: ./gradlew build -P versionName=${{ steps.ghd.outputs.describe }} + - name: Run Tests and Build Test Artifacts + if: github.event_name != 'workflow_dispatch' || env.HAS_SIGNING_KEY != 'true' + run: ./gradlew test assembleNormalDebug -P versionName=${{ steps.ghd.outputs.describe }} + + - name: Build Official Release (Explicit Workflow Dispatch with Key) + if: github.event_name == 'workflow_dispatch' && env.HAS_SIGNING_KEY == 'true' + run: ./gradlew build -P versionName=${{ steps.ghd.outputs.describe }} + - name: Verify project signature + if: github.event_name == 'workflow_dispatch' && env.HAS_SIGNING_KEY == 'true' + shell: bash + run: | + apk=(push/build/outputs/apk/normal/release/*.apk) + apksigner_path=$(find "$ANDROID_HOME/build-tools" -type f -name apksigner | sort -V | tail -n 1) + test -n "$apksigner_path" + actual=$("$apksigner_path" verify --print-certs "${apk[0]}" 2>&1 \ + | awk -F': ' '/certificate SHA-256 digest/ {print $2; exit}' \ + | tr -d ':[:space:]' | tr '[:upper:]' '[:lower:]') + test "$actual" = "108209e30f644a23f43fe14aaae45f76e0639edaf079a765ff40aa18efcc6b83" - name: Collect artifact name run: | - for variant in vc105 normal; do - for build_type in debug release; do - artifact_name=$(basename -s .apk push/build/outputs/apk/$variant/$build_type/*.apk) - echo "${variant}_${build_type}_artifact=$artifact_name" >> $GITHUB_ENV - done + for build_type in debug release; do + if compgen -G "push/build/outputs/apk/normal/$build_type/*.apk" > /dev/null; then + artifact_name=$(basename -s .apk push/build/outputs/apk/normal/$build_type/*.apk) + echo "normal_${build_type}_artifact=$artifact_name" >> $GITHUB_ENV + fi done - name: Upload Release For Normal + if: env.normal_release_artifact != '' uses: actions/upload-artifact@v4.6.0 with: name: ${{ env.normal_release_artifact }} path: push/build/outputs/apk/normal/release/*.apk - - name: Upload Release For VC105 - uses: actions/upload-artifact@v4.6.0 - with: - name: ${{ env.vc105_release_artifact }} - path: push/build/outputs/apk/vc105/release/*.apk - - name: Upload Debug For Normal + if: env.normal_debug_artifact != '' uses: actions/upload-artifact@v4.6.0 with: name: ${{ env.normal_debug_artifact }} path: push/build/outputs/apk/normal/debug/*.apk - - name: Upload Debug For VC105 - uses: actions/upload-artifact@v4.6.0 - with: - name: ${{ env.vc105_debug_artifact }} - path: push/build/outputs/apk/vc105/debug/*.apk - - name: Get Version Name id: gvn run: | - artifact_name=(push/build/outputs/apk/*/*/*.apk) - version=$(echo $artifact_name | sed 's/.*(v.*)(-[^-]+){2}/\1/' -r) - echo "version=$version" >> $GITHUB_OUTPUT + if compgen -G "push/build/outputs/apk/normal/release/*.apk" > /dev/null; then + artifact_name=(push/build/outputs/apk/normal/release/*.apk) + version=$(echo $artifact_name | sed 's/.*(v.*)(-[^-]+){2}/\1/' -r) + echo "version=$version" >> $GITHUB_OUTPUT + fi - name: Release + if: >- + github.event_name == 'workflow_dispatch' && + env.HAS_SIGNING_KEY == 'true' && + env.normal_release_artifact != '' && + steps.gvn.outputs.version != '' uses: softprops/action-gh-release@v2 with: name: ${{ steps.gvn.outputs.version }} tag_name: ${{ steps.gvn.outputs.version }} target_commitish: ${{ steps.ghd.outputs.sha }} prerelease: true - files: push/build/outputs/apk/*/*/*.apk + files: push/build/outputs/apk/normal/release/*.apk diff --git a/.gitignore b/.gitignore index 97bc49b2d..a83343f63 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,15 @@ *.iml .gradle +.kotlin/ /local.properties .DS_Store /build /captures .externalNativeBuild .yuuta.jks +*.jks +*.keystore +.mipush-project-signing.env gradle.properties gen/ debug/ @@ -14,3 +18,4 @@ release/ .project org.eclipse.buildship.core.prefs .idea +.analysis/ diff --git a/README.md b/README.md index 865dd9208..1ee412eb1 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,8 @@ - 配置文件都有什么作用?我应该使用配置文件吗? - 配置文件可以修改消息的标题、内容及样式等,控制收到消息时忽略、亮屏或自动弹出等 - 目前大部分“官方”配置都可以无脑使用,部分配置是否要使用,参见[仓库说明](https://github.com/NihilityT/MiPushConfigurations)、配置名或配置中的 description 字段 + - 应用原始推送中可用的点击路由默认优先于配置文件对 `url`、`notify_effect`、`intent_uri`、`class_name`、`web_uri` 或 `intent_flag` 的改写,以保留应用自己的页面跳转与返回栈 + - 如果配置确实需要替换点击路由,请同时在 `newMetaInfo.extra` 中写入 `"__mi_push_allow_click_route_rewrite": "true"`;框架仍会校验目标 Activity 属于通知对应的应用 - 是否应该安装为系统应用? @@ -118,6 +120,20 @@ * 锤子 ROM 下,Push 可以正确收到通知,但是通知栏没有提示 #143 * 一些通知 Feature 可能无法使用(如通知都会显示为推送框架发出,而不是目标应用) +## 界面参考与第三方归因 + +悬浮底部导航栏的交互和几何布局参考了 KernelSU Manager 的公开实现,具体包括 +`FloatingBottomBar.kt` 与 `BottomBarMiuix.kt`: + +* [`FloatingBottomBar.kt`](https://github.com/tiann/KernelSU/blob/main/manager/app/src/main/java/me/weishu/kernelsu/ui/component/FloatingBottomBar.kt) +* [`BottomBarMiuix.kt`](https://github.com/tiann/KernelSU/blob/main/manager/app/src/main/java/me/weishu/kernelsu/ui/component/bottombar/BottomBarMiuix.kt) +* [KernelSU LICENSE](https://github.com/tiann/KernelSU/blob/main/LICENSE)(GPL-3.0) + +本项目使用 Miuix 0.2.x API 对该交互进行了独立重写,没有复制 KernelSU 的源代码、资源或 +模糊/液态玻璃依赖;因此这里记录的是设计与行为参考,而不是把 KernelSU 代码作为本项目代码 +直接分发。KernelSU 的 `FloatingBottomBar.kt` 文件头另行注明其参考了 Apache-2.0 的 +compose-miuix-ui 示例,本项目没有直接引入该示例。 + ## 感谢 * @Rachel030219 提供文件 diff --git a/agent.md b/agent.md new file mode 100644 index 000000000..693c88452 --- /dev/null +++ b/agent.md @@ -0,0 +1,59 @@ +# Agent build contract + +This repository must be built with JDK 17. This is the Gradle runtime requirement; do not change the existing Android bytecode targets just to match it. + +## Required toolchain + +- JDK: 17.x (`java -version` must report major version 17) +- Android SDK: `C:\Users\vince\AppData\Local\Android\Sdk` unless `ANDROID_SDK_ROOT` points to another verified SDK +- Gradle: use `gradlew.bat` only; never use a globally installed Gradle +- MiPushFramework wrapper: Gradle 8.5, AGP 8.2.2, Kotlin 2.0.21 +- Java/Kotlin compile target: 11, as defined by `push/build.gradle`; this is intentionally different from the JDK runtime + +The currently verified local JDK 17 is: + +```powershell +C:\Users\vince\MiPushFramework\.tmp-temurin17\jdk-17.0.20+8 +``` + +Prefer a stable installed JDK 17 path when available. Set `JAVA_HOME` explicitly for every build and verify it before invoking Gradle: + +```powershell +$env:JAVA_HOME = 'C:\path\to\jdk-17' +$env:ANDROID_SDK_ROOT = 'C:\Users\vince\AppData\Local\Android\Sdk' +& "$env:JAVA_HOME\bin\java.exe" -version +``` + +If the reported Java major version is not 17, stop; do not attempt a build with JDK 21 or another version. + +## Stable build procedure + +Run builds serially. Do not run Android Studio/Gradle builds concurrently with these commands, and do not switch repositories while a Gradle process is running. + +```powershell +.\gradlew.bat --stop +.\gradlew.bat :push:testNormalDebugUnitTest :push:assembleNormalDebug ` + --no-daemon --max-workers=1 --console=plain ` + "-Dkotlin.compiler.execution.strategy=in-process" +``` + +Do not run `clean` routinely; it removes useful incremental outputs and makes the next build a full rebuild. If a Windows `AccessDeniedException` names a Gradle/Kotlin temporary file or build JAR, stop daemons first and retry the same command once. Only investigate/remove the specific locked build artifact after a repeated failure; never delete source, `.git`, or the whole Gradle cache. + +## Dirty and reproducibility rules + +- `dirty` in an APK name comes from `git describe --dirty` and means tracked source changes are not committed. It is a version label, not a build mode or performance setting. +- Before handing off an APK, run `git status --short --untracked-files=no`; commit tracked source changes so the artifact has a reproducible commit-based name. +- Do not stage `.tmp-*`, screenshots, device dumps, APKs, or other sampling artifacts. + +## Verification + +At minimum, report the exact JDK version, Gradle task, result, and APK path. A failed build caused by toolchain/ACL setup must be reported separately from a source compilation failure. + +## Project signing contract + +- The project-owned keystore is local-only at `C:\Users\vince\MiPushFramework\.mipush-project.jks`. +- Never stage or publish the keystore, `.mipush-project-signing.env`, `local.properties`, or any password. +- The main app and the LSP companion use the same alias and certificate. Expected certificate SHA-256 fingerprint: + `10:82:09:E3:0F:64:4A:23:F4:3F:E1:4A:AA:E4:5F:76:E0:63:9E:DA:F0:79:A7:65:FF:40:AA:18:EF:CC:6B:83`. +- CI uses repository secrets `MIPUSH_SIGNING_KEY`, `MIPUSH_KEYSTORE_PASSWORD`, `MIPUSH_KEY_ALIAS`, and `MIPUSH_KEY_PASSWORD`. The base64 keystore secret must be set independently on each fork. +- A release artifact is valid only when `apksigner verify --print-certs` reports the expected fingerprint. A debug artifact signed by another key must not be used for an upgrade. diff --git a/build.gradle b/build.gradle index 48509179f..a31b56abd 100644 --- a/build.gradle +++ b/build.gradle @@ -1,39 +1,47 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { - ext.kotlin_version = '1.8.0' + ext.kotlin_version = '2.0.21' repositories { + // Prefer the canonical repositories on CI. A transient 5xx from the + // Aliyun mirror is treated as a hard resolution failure by Gradle and + // prevents it from falling through to the repositories below. + google() + mavenCentral() + gradlePluginPortal() + maven { url 'https://jitpack.io' } + + // Keep the mirrors as a fallback for users on networks where the + // canonical endpoints are unavailable. maven { url = uri("https://maven.aliyun.com/repository/central") } maven { url = uri("https://maven.aliyun.com/repository/public") } maven { url = uri("https://maven.aliyun.com/repository/gradle-plugin") } maven { url = uri("https://maven.aliyun.com/repository/google") } - - mavenCentral() - maven { url 'https://jitpack.io' } - gradlePluginPortal() - google() } dependencies { - classpath 'com.android.tools.build:gradle:7.4.2' - classpath 'org.greenrobot:greendao-gradle-plugin:3.3.0' + classpath 'com.android.tools.build:gradle:8.2.2' + classpath 'org.greenrobot:greendao-gradle-plugin:3.3.1' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" - classpath 'io.github.wurensen:gradle-android-plugin-aspectjx:3.3.2' + classpath "org.jetbrains.kotlin:compose-compiler-gradle-plugin:$kotlin_version" + classpath 'io.freefair.gradle:android-gradle-plugins:8.2.2' } } allprojects { repositories { + // See buildscript.repositories above. The order is intentional: + // canonical sources first, mirrors only as a fallback. + google() + mavenCentral() + gradlePluginPortal() + maven { url 'https://jitpack.io' } + maven { url = uri("https://maven.aliyun.com/repository/central") } maven { url = uri("https://maven.aliyun.com/repository/public") } maven { url = uri("https://maven.aliyun.com/repository/gradle-plugin") } maven { url = uri("https://maven.aliyun.com/repository/google") } - - mavenCentral() - maven { url 'https://jitpack.io' } - gradlePluginPortal() - google() } gradle.taskGraph.whenReady { @@ -100,8 +108,8 @@ private String getVersionNameFromGit() { } ext { - minSdkVersion = 21 - compileSdkVersion = 33 + minSdkVersion = 26 + compileSdkVersion = 34 targetSdkVersion = 30 pushVersionCode = 7 diff --git a/common/build.gradle b/common/build.gradle index 76c209c5f..d668b27e5 100644 --- a/common/build.gradle +++ b/common/build.gradle @@ -23,6 +23,9 @@ android { sourceCompatibility JavaVersion.VERSION_11 targetCompatibility JavaVersion.VERSION_11 } + buildFeatures { + buildConfig true + } lintOptions { abortOnError false } diff --git a/common/src/main/java/top/trumeet/common/Constants.java b/common/src/main/java/top/trumeet/common/Constants.java index 577a34d4f..af790bf3f 100644 --- a/common/src/main/java/top/trumeet/common/Constants.java +++ b/common/src/main/java/top/trumeet/common/Constants.java @@ -83,20 +83,12 @@ private Constants () { */ public static final String LOG_FILE = "/file.log"; - public static final String AUTHORITY_FILE_PROVIDER = "top.trumeet.mipushframework.fileprovider"; - public static final String SERVICE_APP_NAME = "com.xiaomi.xmsf"; public static final String MANAGER_APP_NAME = "top.trumeet.mipush"; public static final int PUSH_SERVICE_VERSION_CODE = Integer.parseInt(BuildConfig.PUSH_VERSION_CODE); - public static final String SHARE_LOG_COMPONENT_NAME = - SERVICE_APP_NAME + ".ShareLogActivity"; - - public static final String REMOVE_DOZE_COMPONENT_NAME = - SERVICE_APP_NAME + ".RemoveDozeActivity"; - public static final String INTENT_NOTIFICATION_ID = "mipush_notification_id"; public static final String INTENT_NOTIFICATION_GROUP = "mipush_notification_group"; diff --git a/common/src/main/java/top/trumeet/common/cache/ApplicationNameCache.java b/common/src/main/java/top/trumeet/common/cache/ApplicationNameCache.java index dc9ce5097..58b5246f4 100644 --- a/common/src/main/java/top/trumeet/common/cache/ApplicationNameCache.java +++ b/common/src/main/java/top/trumeet/common/cache/ApplicationNameCache.java @@ -39,5 +39,21 @@ CharSequence gen() { }.get(pkg); } + /** Names are cheap to reload and may be discarded when the process is under memory pressure. */ + public void clearMemory() { + cacheInstance.evictAll(); + } + + /** + * Apply the process memory-pressure policy to this small, UI-oriented cache. + * Names are inexpensive to resolve, so dropping them is preferable to keeping + * stale objects alive while the process is backgrounded. + */ + public void trimMemory(int level) { + if (level >= android.content.ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW) { + clearMemory(); + } + } + } diff --git a/common/src/main/java/top/trumeet/common/cache/IconCache.java b/common/src/main/java/top/trumeet/common/cache/IconCache.java index 84036554d..fd83887ee 100644 --- a/common/src/main/java/top/trumeet/common/cache/IconCache.java +++ b/common/src/main/java/top/trumeet/common/cache/IconCache.java @@ -85,6 +85,46 @@ IconCompat gen() { }.get("white_" + pkg); } + /** + * Release decoded icon memory when the process is under pressure. The caches are only + * accelerators; dropping them cannot affect notification delivery because every miss reloads + * the package icon on demand. + */ + public void trimMemory(int level) { + // Once the UI is hidden, these decoded bitmaps no longer provide user + // value. Evict them completely so a background push process can remain + // small on memory-constrained/vendor devices. + if (level >= android.content.ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN + || level >= android.content.ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL) { + clearMemory(); + } else if (level >= android.content.ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW) { + trimToSize(bitmapLruCache.maxSize() / 2, + mIconMemoryCaches.maxSize() / 2, + appColorCache.maxSize() / 2, + bitmapCache.maxSize() / 2); + } else if (level >= android.content.ComponentCallbacks2.TRIM_MEMORY_RUNNING_MODERATE) { + trimToSize(Math.max(1, bitmapLruCache.maxSize() / 4), + Math.max(1, mIconMemoryCaches.maxSize() / 4), + Math.max(1, appColorCache.maxSize() / 4), + Math.max(1, bitmapCache.maxSize() / 4)); + } + } + + public void clearMemory() { + bitmapLruCache.evictAll(); + mIconMemoryCaches.evictAll(); + appColorCache.evictAll(); + bitmapCache.evictAll(); + } + + private void trimToSize(int rawIconMaxSize, int whiteIconMaxSize, + int colorMaxSize, int bitmapMaxSize) { + bitmapLruCache.trimToSize(rawIconMaxSize); + mIconMemoryCaches.trimToSize(whiteIconMaxSize); + appColorCache.trimToSize(colorMaxSize); + bitmapCache.trimToSize(bitmapMaxSize); + } + public int getAppColor(final Context ctx, final String pkg, Converter callback) { return new AbstractCacheAspect(appColorCache) { diff --git a/common/src/main/java/top/trumeet/common/push/PushServiceAccessibility.java b/common/src/main/java/top/trumeet/common/push/PushServiceAccessibility.java index 30c2a1d81..7913507c3 100644 --- a/common/src/main/java/top/trumeet/common/push/PushServiceAccessibility.java +++ b/common/src/main/java/top/trumeet/common/push/PushServiceAccessibility.java @@ -5,8 +5,6 @@ import android.os.Build; import android.os.PowerManager; -import top.trumeet.common.Constants; - /** * Created by Trumeet on 2017/8/25. * A util class to check XMPush accessibility @@ -26,6 +24,6 @@ public static boolean isInDozeWhiteList(Context context) { return true; } PowerManager powerManager = context.getSystemService(PowerManager.class); - return powerManager.isIgnoringBatteryOptimizations(Constants.SERVICE_APP_NAME); + return powerManager.isIgnoringBatteryOptimizations(context.getPackageName()); } } diff --git a/common/src/main/java/top/trumeet/common/utils/AlarmSchedulePolicy.java b/common/src/main/java/top/trumeet/common/utils/AlarmSchedulePolicy.java new file mode 100644 index 000000000..37fba3e98 --- /dev/null +++ b/common/src/main/java/top/trumeet/common/utils/AlarmSchedulePolicy.java @@ -0,0 +1,34 @@ +package top.trumeet.common.utils; + +/** + * Pure policy to determine alarm scheduling strategy across Android API levels and permissions. + */ +public class AlarmSchedulePolicy { + + public enum AlarmScheduleType { + EXACT, + INEXACT_ALLOW_WHILE_IDLE + } + + public static AlarmScheduleType determineScheduleType(int sdkInt, boolean canScheduleExactAlarms) { + return determineScheduleType(sdkInt, canScheduleExactAlarms, false); + } + + /** + * Select alarm precision while respecting the user's/system battery saver + * choice. In power-save mode an inexact idle-aware alarm still delivers the + * event, while allowing the platform to coalesce wakeups and reduce drain. + */ + public static AlarmScheduleType determineScheduleType( + int sdkInt, boolean canScheduleExactAlarms, boolean powerSaveMode) { + if (powerSaveMode && sdkInt >= 23) { + return AlarmScheduleType.INEXACT_ALLOW_WHILE_IDLE; + } + if (sdkInt >= 31) { + return canScheduleExactAlarms + ? AlarmScheduleType.EXACT + : AlarmScheduleType.INEXACT_ALLOW_WHILE_IDLE; + } + return AlarmScheduleType.EXACT; + } +} diff --git a/common/src/main/java/top/trumeet/common/utils/CustomConfiguration.java b/common/src/main/java/top/trumeet/common/utils/CustomConfiguration.java index 6a5c4b8d5..421eeec73 100644 --- a/common/src/main/java/top/trumeet/common/utils/CustomConfiguration.java +++ b/common/src/main/java/top/trumeet/common/utils/CustomConfiguration.java @@ -2,8 +2,15 @@ import androidx.annotation.Nullable; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; import java.util.HashMap; import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Set; @@ -31,12 +38,46 @@ private static String Config(String name) { private static final String CHANNEL_ID = "channel_id"; private static final String CHANNEL_NAME = "channel_name"; private static final String CHANNEL_DESCRIPTION = "channel_description"; + private static final String CHANNEL_IMPORTANCE = "channel_importance"; private static final String SOUND_URL = "sound_url"; + private static final String SOUND_URI = "sound_uri"; private static final String JOBKEY = "jobkey"; private static final String USE_CLICKED_ACTIVITY = "use_clicked_activity"; private static final String NOTIFICATION_GROUP = "notification_group"; private static final String NOTIFICATION_BIGPIC_URI = "notification_bigPic_uri"; + private static final String NOTIFICATION_SHOW_WHEN = "notification_show_when"; + private static final String NOTIFICATION_STYLE_TYPE = "notification_style_type"; + private static final String NOTIFICATION_BANNER_IMAGE_URI = "notification_banner_image_uri"; + private static final String NOTIFICATION_BANNER_ICON_URI = "notification_banner_icon_uri"; + private static final String NOTIFICATION_COLORFUL_BUTTON_TEXT = "notification_colorful_button_text"; + private static final String NOTIFICATION_COLORFUL_BUTTON_BG_COLOR = "notification_colorful_button_bg_color"; + private static final String NOTIFICATION_COLORFUL_BG_COLOR = "notification_colorful_bg_color"; + private static final String NOTIFICATION_COLORFUL_BG_IMAGE_URI = "notification_colorful_bg_image_uri"; + // Kept only for payloads produced by older MiPush Framework versions. + private static final String NOTIFICATION_COLORFUL_BUTTON_BG_IMAGE_URI = "notification_colorful_button_bg_image_uri"; + private static final String NOTIFICATION_CUSTOM_SMALL_ICON_URI = "notification_custom_small_icon_uri"; + private static final String NOTIFICATION_SMALL_ICON_URI = "notification_small_icon_uri"; + private static final String NOTIFICATION_SMALL_ICON_COLOR = "notification_small_icon_color"; + private static final String IMAGE_DESCRIPTION = "img_describe"; + private static final String NOTIFICATION_TIMEOUT = "notification_timeout"; + private static final String NOTIFICATION_BACKGROUND_COLOR = "background_color"; + private static final String ENABLE_KEYGUARD = "enable_keyguard"; + private static final String ENABLE_FLOAT = "enable_float"; + private static final String NOTIFICATION_FOLD = "notification_fold"; + private static final String MIUI_FOLD_TIMEOUT = "miui.fold.timeout"; private static final String FOCUS_PARAM = "miui.focus.param"; + /** + * HyperOS' custom focus renderer receives a second JSON parameter. This is + * distinct from the public {@code miui.focus.param} payload used by the + * original XMSF helper. + */ + private static final String FOCUS_PARAM_CUSTOM = "miui.focus.param.custom"; + private static final String FOCUS_PICTURE_PREFIX = "miui.focus.pic_"; + + /** Limits published by Xiaomi for the focus-notification protocol. */ + public static final int FOCUS_PARAM_MAX_BYTES = 3072; + public static final int FOCUS_PICTURE_MAX_COUNT = 10; + public static final int FOCUS_PICTURE_MAX_BYTES = 100 * 1024; private Map mExtra = new HashMap<>(); @@ -106,8 +147,29 @@ public String channelDescription(String defaultValue) { return get(CHANNEL_DESCRIPTION, defaultValue); } + public int channelImportance(int defaultValue) { + String value = get(CHANNEL_IMPORTANCE, null); + if (value == null) { + return defaultValue; + } + try { + int importance = Integer.parseInt(value); + return importance >= 0 && importance <= 5 ? importance : defaultValue; + } catch (NumberFormatException ignored) { + return defaultValue; + } + } + + /** + * Xiaomi's current protocol uses {@code sound_uri}. Keep accepting the older + * {@code sound_url} spelling so existing local configuration files do not break. + */ + public String soundUri(String defaultValue) { + return get(SOUND_URI, get(SOUND_URL, defaultValue)); + } + public String soundUrl(String defaultValue) { - return get(SOUND_URL, defaultValue); + return soundUri(defaultValue); } public String jobkey(String defaultValue) { @@ -115,7 +177,7 @@ public String jobkey(String defaultValue) { } public boolean useClickedActivity(boolean defaultValue) { - return get(USE_CLICKED_ACTIVITY, defaultValue); + return getBooleanValue(USE_CLICKED_ACTIVITY, defaultValue); } public String notificationGroup(String defaultValue) { @@ -126,6 +188,109 @@ public String notificationBigPicUri(String defaultValue) { return get(NOTIFICATION_BIGPIC_URI, defaultValue); } + public boolean notificationShowWhen(boolean defaultValue) { + return getBooleanValue(NOTIFICATION_SHOW_WHEN, defaultValue); + } + + public String notificationStyleType(String defaultValue) { + return get(NOTIFICATION_STYLE_TYPE, defaultValue); + } + + public NotificationStyle notificationStyle() { + return NotificationStyle.fromProtocolValue(notificationStyleType(null)); + } + + public String notificationBannerImageUri(String defaultValue) { + return get(NOTIFICATION_BANNER_IMAGE_URI, defaultValue); + } + + public String notificationBannerIconUri(String defaultValue) { + return get(NOTIFICATION_BANNER_ICON_URI, defaultValue); + } + + public String notificationColorfulButtonText(String defaultValue) { + return get(NOTIFICATION_COLORFUL_BUTTON_TEXT, defaultValue); + } + + public String notificationColorfulButtonBackgroundColor(String defaultValue) { + return get(NOTIFICATION_COLORFUL_BUTTON_BG_COLOR, defaultValue); + } + + public String notificationColorfulBackgroundColor(String defaultValue) { + return get(NOTIFICATION_COLORFUL_BG_COLOR, defaultValue); + } + + /** + * Xiaomi's published key wins whenever it is present. The button-background + * image spelling was previously used by this project for the whole colorful + * background and remains an absent-key fallback for compatible old payloads. + */ + public String notificationColorfulBackgroundImageUri(String defaultValue) { + return get(NOTIFICATION_COLORFUL_BG_IMAGE_URI, + get(NOTIFICATION_COLORFUL_BUTTON_BG_IMAGE_URI, defaultValue)); + } + + public String notificationColorfulButtonBackgroundImageUri(String defaultValue) { + return notificationColorfulBackgroundImageUri(defaultValue); + } + + public enum NotificationStyle { + DEFAULT, + BIG_TEXT, + BIG_PICTURE, + COLORFUL, + BANNER; + + public static NotificationStyle fromProtocolValue(@Nullable String value) { + if ("1".equals(value)) return BIG_TEXT; + if ("2".equals(value)) return BIG_PICTURE; + // Official XMSF mapping: 3 is Colorful and 4 is Banner. + if ("3".equals(value)) return COLORFUL; + if ("4".equals(value)) return BANNER; + return DEFAULT; + } + } + + public String notificationCustomSmallIconUri(String defaultValue) { + return get(NOTIFICATION_CUSTOM_SMALL_ICON_URI, defaultValue); + } + + public String notificationSmallIconUri(String defaultValue) { + return get(NOTIFICATION_SMALL_ICON_URI, defaultValue); + } + + public String notificationSmallIconColor(String defaultValue) { + return get(NOTIFICATION_SMALL_ICON_COLOR, defaultValue); + } + + public String imageDescription(String defaultValue) { + return get(IMAGE_DESCRIPTION, defaultValue); + } + + public int notificationTimeoutSeconds(int defaultValue) { + return boundedInt(NOTIFICATION_TIMEOUT, defaultValue, 0, 7 * 24 * 60 * 60); + } + + public String notificationBackgroundColor(String defaultValue) { + return get(NOTIFICATION_BACKGROUND_COLOR, defaultValue); + } + + public boolean enableKeyguard(boolean defaultValue) { + return getBooleanValue(ENABLE_KEYGUARD, defaultValue); + } + + public boolean enableFloat(boolean defaultValue) { + return getBooleanValue(ENABLE_FLOAT, defaultValue); + } + + public boolean notificationFold(boolean defaultValue) { + return getBooleanValue(NOTIFICATION_FOLD, defaultValue); + } + + public int miuiFoldTimeoutSeconds(int defaultValue) { + return boundedInt(MIUI_FOLD_TIMEOUT, defaultValue, 0, 7 * 24 * 60 * 60); + } + public boolean clearGroup(boolean defaultValue) { return get(CLEAR_GROUP, defaultValue); } @@ -137,6 +302,241 @@ public String focusParam(String defaultValue) { return get(FOCUS_PARAM, defaultValue); } + /** + * Return the optional HyperOS custom-focus JSON verbatim. A bounded object + * candidate is retained by {@link FocusNotificationPayload}; the push + * module performs full JSON validation. Keeping this accessor string-based + * is important because PushMetaInfo.extra is a + * Map<String,String> and cannot carry a RemoteViews/Bundle object. + */ + public String focusParamCustom(String defaultValue) { + return get(FOCUS_PARAM_CUSTOM, defaultValue); + } + + /** + * Parse the documented, public part of Xiaomi's focus-notification payload. + * Picture values are forwarded exactly like official XMSF. Only this + * process' optional native-Icon downloads apply URI safety filtering. + */ + public FocusNotificationPayload focusNotificationPayload() { + String parameter = focusParam(null); + if (!FocusNotificationPayload.isParameterWithinLimit(parameter)) { + parameter = null; + } + + String customParameter = focusParamCustom(null); + // Keep malformed/scalar values out of the focus payload at the + // configuration boundary. The push module performs the full JSON parse + // before handing the value to HyperOS; this inexpensive shape check + // avoids carrying obvious garbage through every notification path. + if (!FocusNotificationPayload.isJsonObjectCandidate(customParameter)) { + customParameter = null; + } + + List> pictureEntries = new ArrayList<>(); + for (Map.Entry entry : mExtra.entrySet()) { + String key = entry.getKey(); + if (key != null && key.startsWith(FOCUS_PICTURE_PREFIX)) { + pictureEntries.add(entry); + } + } + pictureEntries.sort(Comparator.comparing(Map.Entry::getKey, + CustomConfiguration::compareNaturally)); + + Map pictures = new LinkedHashMap<>(); + for (Map.Entry entry : pictureEntries) { + // The URL part of the protocol is forwarded in full. The native Icon + // bundle is deliberately capped separately by downloadPictureUrls(). + pictures.put(entry.getKey(), entry.getValue()); + } + return new FocusNotificationPayload(parameter, customParameter, pictures); + } + + /** Compare digit runs by numeric value so pic_2 sorts before pic_10. */ + private static int compareNaturally(String left, String right) { + int leftIndex = 0; + int rightIndex = 0; + while (leftIndex < left.length() && rightIndex < right.length()) { + char leftChar = left.charAt(leftIndex); + char rightChar = right.charAt(rightIndex); + if (Character.isDigit(leftChar) && Character.isDigit(rightChar)) { + int leftEnd = leftIndex; + int rightEnd = rightIndex; + while (leftEnd < left.length() && Character.isDigit(left.charAt(leftEnd))) { + leftEnd++; + } + while (rightEnd < right.length() && Character.isDigit(right.charAt(rightEnd))) { + rightEnd++; + } + + int leftSignificant = leftIndex; + int rightSignificant = rightIndex; + while (leftSignificant < leftEnd - 1 && left.charAt(leftSignificant) == '0') { + leftSignificant++; + } + while (rightSignificant < rightEnd - 1 && right.charAt(rightSignificant) == '0') { + rightSignificant++; + } + + int lengthComparison = Integer.compare( + leftEnd - leftSignificant, rightEnd - rightSignificant); + if (lengthComparison != 0) { + return lengthComparison; + } + for (int i = 0; i < leftEnd - leftSignificant; i++) { + int digitComparison = Character.compare( + left.charAt(leftSignificant + i), + right.charAt(rightSignificant + i)); + if (digitComparison != 0) { + return digitComparison; + } + } + + int zeroPaddingComparison = Integer.compare( + leftEnd - leftIndex, rightEnd - rightIndex); + if (zeroPaddingComparison != 0) { + return zeroPaddingComparison; + } + leftIndex = leftEnd; + rightIndex = rightEnd; + continue; + } + + int charComparison = Character.compare(leftChar, rightChar); + if (charComparison != 0) { + return charComparison; + } + leftIndex++; + rightIndex++; + } + return Integer.compare(left.length() - leftIndex, right.length() - rightIndex); + } + + private static boolean isSupportedPictureValue(@Nullable String value) { + if (value == null) { + return false; + } + String lower = value.toLowerCase(Locale.ROOT); + int authorityStart; + if (lower.startsWith("https://")) { + authorityStart = 8; + } else if (lower.startsWith("content://")) { + // Official XMSF accepts content/resource URIs and lets the platform + // resolver enforce the caller's grants. Do not accept file:// paths. + authorityStart = 10; + } else if (lower.startsWith("android.resource://")) { + authorityStart = 19; + } else { + return false; + } + int authorityEnd = value.length(); + for (char delimiter : new char[]{'/', '?', '#'}) { + int index = value.indexOf(delimiter, authorityStart); + if (index >= 0 && index < authorityEnd) { + authorityEnd = index; + } + } + if (authorityEnd <= authorityStart) { + return false; + } + String authority = value.substring(authorityStart, authorityEnd); + // User-info and whitespace are unnecessary for network/resource values and + // can make an apparently valid URI resolve somewhere unexpected. + return authority.indexOf('@') < 0 && !containsAsciiWhitespace(authority); + } + + private static boolean containsAsciiWhitespace(String value) { + for (int i = 0; i < value.length(); i++) { + if (value.charAt(i) <= ' ') { + return true; + } + } + return false; + } + + public static final class FocusNotificationPayload { + private final String parameter; + private final String customParameter; + private final Map pictureUrls; + + private FocusNotificationPayload(@Nullable String parameter, + @Nullable String customParameter, + Map pictureUrls) { + this.parameter = parameter; + this.customParameter = customParameter; + this.pictureUrls = Collections.unmodifiableMap( + new LinkedHashMap<>(pictureUrls)); + } + + @Nullable + public String parameter() { + return parameter; + } + + /** Optional HyperOS custom-focus JSON consumed by the CUSTOM renderer. */ + @Nullable + public String customParameter() { + return customParameter; + } + + public Map pictureUrls() { + return pictureUrls; + } + + /** URLs selected for native Icon downloads; the URL payload remains complete. */ + public Map downloadPictureUrls() { + Map result = new LinkedHashMap<>(); + for (Map.Entry entry : pictureUrls.entrySet()) { + if (result.size() >= FOCUS_PICTURE_MAX_COUNT) { + break; + } + if (isSupportedPictureValue(entry.getValue())) { + result.put(entry.getKey(), entry.getValue()); + } + } + return Collections.unmodifiableMap(result); + } + + public boolean isUsable() { + return (parameter != null && !parameter.trim().isEmpty()) + || (customParameter != null && !customParameter.trim().isEmpty()) + || !pictureUrls.isEmpty(); + } + + public static boolean isSupportedProtocolVersion(int version) { + // The official client enables the focus payload for every positive + // protocol value. Future protocol revisions must continue to receive + // the URL payload instead of being silently downgraded to a normal + // notification. + return version > 0; + } + + public static boolean isParameterWithinLimit(@Nullable String parameter) { + return parameter != null + && parameter.getBytes(StandardCharsets.UTF_8).length + <= FOCUS_PARAM_MAX_BYTES; + } + + /** + * Cheap boundary check used by the common module, which deliberately + * has no JSON parser dependency. The push module's Gson-backed safety + * check remains authoritative and rejects malformed object syntax. + */ + public static boolean isJsonObjectCandidate(@Nullable String parameter) { + if (!isParameterWithinLimit(parameter)) { + return false; + } + String value = parameter.trim(); + return value.length() >= 2 + && value.charAt(0) == '{' + && value.charAt(value.length() - 1) == '}'; + } + + public static boolean isPictureSizeAllowed(long downloadSize) { + return downloadSize >= 0 && downloadSize <= FOCUS_PICTURE_MAX_BYTES; + } + } + public String textIcon(String defaultValue) { return get(TEXT_ICON, defaultValue); } @@ -148,6 +548,11 @@ public boolean get(String key, boolean defaultValue) { return defaultValue; } + public boolean getBooleanValue(String key, boolean defaultValue) { + String value = getExtraField(mExtra, key, null); + return value == null ? defaultValue : Boolean.parseBoolean(value); + } + public String get(String key, String defaultValue) { return getExtraField(mExtra, key, defaultValue); } @@ -159,6 +564,19 @@ public Set keys() { return mExtra.keySet(); } + private int boundedInt(String key, int defaultValue, int min, int max) { + String value = get(key, null); + if (value == null) { + return defaultValue; + } + try { + int parsed = Integer.parseInt(value); + return parsed >= min && parsed <= max ? parsed : defaultValue; + } catch (NumberFormatException ignored) { + return defaultValue; + } + } + private static String getExtraField(Map extra, String extraChannelName, String defaultValue) { return extra != null && extra.containsKey(extraChannelName) ? extra.get(extraChannelName) : defaultValue; diff --git a/common/src/main/java/top/trumeet/common/utils/DeviceFocusPolicy.java b/common/src/main/java/top/trumeet/common/utils/DeviceFocusPolicy.java new file mode 100644 index 000000000..00e14042f --- /dev/null +++ b/common/src/main/java/top/trumeet/common/utils/DeviceFocusPolicy.java @@ -0,0 +1,65 @@ +package top.trumeet.common.utils; + +import androidx.annotation.Nullable; + +/** + * Decides which notification renderer owns a focus payload. + * + *

MIUI/HyperOS SystemUI understands Xiaomi's private {@code miui.focus.*} + * extras. Other ROMs do not, so forwarding those extras there only produces a + * normal notification (or, on some vendor builds, an empty custom view). The + * portable renderer is therefore deliberately selected outside Xiaomi's + * SystemUI environment.

+ */ +public final class DeviceFocusPolicy { + public enum Renderer { + /** Let Xiaomi/HyperOS SystemUI consume the private focus protocol. */ + SYSTEM, + /** Render a visible Android notification using portable styles. */ + PORTABLE + } + + private DeviceFocusPolicy() { + } + + /** + * Resolve the renderer from package/build signals without depending on an + * Android {@code Context}; this keeps policy deterministic and testable. + * + * @param systemUiPackage package hosting the active status-bar/SystemUI + * @param manufacturer build manufacturer (for Xiaomi vendor variants) + * @param focusProtocolVersion value of {@code notification_focus_protocol} + */ + public static Renderer rendererFor( + @Nullable String systemUiPackage, + @Nullable String manufacturer, + int focusProtocolVersion) { + if (focusProtocolVersion > 0 + // A package name such as miui.systemui.plugin is not sufficient + // evidence on its own: a compatibility module can expose that + // namespace on an AOSP device. Require the vendor identity and + // a known Xiaomi SystemUI host together. + && isXiaomiManufacturer(manufacturer) + && isXiaomiPackage(systemUiPackage)) { + return Renderer.SYSTEM; + } + return Renderer.PORTABLE; + } + + public static boolean isXiaomiPackage(@Nullable String packageName) { + return "com.android.systemui".equals(packageName) + || "miui.systemui.plugin".equals(packageName) + || "com.miui.aod".equals(packageName); + } + + public static boolean isXiaomiManufacturer(@Nullable String manufacturer) { + if (manufacturer == null) { + return false; + } + String normalized = manufacturer.trim().toLowerCase(java.util.Locale.ROOT); + return "xiaomi".equals(normalized) + || "redmi".equals(normalized) + || "blackshark".equals(normalized) + || "poco".equals(normalized); + } +} diff --git a/common/src/main/java/top/trumeet/common/utils/NotificationAlertUtils.java b/common/src/main/java/top/trumeet/common/utils/NotificationAlertUtils.java new file mode 100644 index 000000000..3ae104e0b --- /dev/null +++ b/common/src/main/java/top/trumeet/common/utils/NotificationAlertUtils.java @@ -0,0 +1,22 @@ +package top.trumeet.common.utils; + +import androidx.annotation.Nullable; + +public final class NotificationAlertUtils { + public static final int NOTIFY_TYPE_SOUND = 1; + public static final int NOTIFY_TYPE_VIBRATE = 2; + public static final int NOTIFY_TYPE_LIGHTS = 4; + + private NotificationAlertUtils() { + } + + public static boolean usesPackageResourceSound( + int notifyType, @Nullable String soundUri, @Nullable String packageName) { + if ((notifyType & NOTIFY_TYPE_SOUND) == 0 + || soundUri == null || soundUri.isEmpty() + || packageName == null || packageName.isEmpty()) { + return false; + } + return soundUri.startsWith("android.resource://" + packageName + "/"); + } +} diff --git a/common/src/main/java/top/trumeet/common/utils/NotificationMetadata.java b/common/src/main/java/top/trumeet/common/utils/NotificationMetadata.java new file mode 100644 index 000000000..7da64e09f --- /dev/null +++ b/common/src/main/java/top/trumeet/common/utils/NotificationMetadata.java @@ -0,0 +1,169 @@ +package top.trumeet.common.utils; + +import androidx.annotation.Nullable; + +import java.util.Locale; + +/** + * Sanitised subset of the HyperOS notification contract. + * + *

This class deliberately has no Android framework dependency (apart from the + * nullable annotation), so the protocol rules can be exercised on a desktop JVM. + * Missing values remain {@code null}; callers can therefore distinguish an + * omitted option from an explicit false/zero value.

+ */ +public final class NotificationMetadata { + public static final int MAX_TIMEOUT_SECONDS = 7 * 24 * 60 * 60; + public static final int MAX_TOP_PERIOD_SECONDS = 7 * 24 * 60 * 60; + public static final int MAX_TOP_FREQUENCY = MAX_TOP_PERIOD_SECONDS; + + @Nullable public final Integer timeoutSeconds; + @Nullable public final Boolean enableKeyguard; + @Nullable public final Boolean enableFloat; + @Nullable public final String fold; + @Nullable public final Integer foldTimeoutSeconds; + @Nullable public final Boolean topRepeat; + @Nullable public final Integer topPeriodSeconds; + @Nullable public final Integer topFrequency; + @Nullable public final Boolean ongoing; + @Nullable public final Boolean colorized; + @Nullable public final Integer backgroundColor; + @Nullable public final Integer visibility; + @Nullable public final String category; + + private NotificationMetadata(Builder b) { + timeoutSeconds = b.timeoutSeconds; + enableKeyguard = b.enableKeyguard; + enableFloat = b.enableFloat; + fold = b.fold; + foldTimeoutSeconds = b.foldTimeoutSeconds; + topRepeat = b.topRepeat; + topPeriodSeconds = b.topPeriodSeconds; + topFrequency = b.topFrequency; + ongoing = b.ongoing; + colorized = b.colorized; + backgroundColor = b.backgroundColor; + visibility = b.visibility; + category = b.category; + } + + public static NotificationMetadata from(CustomConfiguration configuration) { + Builder b = new Builder(); + if (configuration == null) return b.build(); + b.timeoutSeconds = intValue(configuration, "timeout", 0, MAX_TIMEOUT_SECONDS); + if (b.timeoutSeconds == null) { + b.timeoutSeconds = intValue(configuration, "notification_timeout", 0, MAX_TIMEOUT_SECONDS); + } + b.enableKeyguard = boolValue(configuration, "enable_keyguard"); + b.enableFloat = boolValue(configuration, "enable_float"); + b.fold = boundedText(configuration.get("notification_fold", null), 64); + b.foldTimeoutSeconds = intValue(configuration, "miui.fold.timeout", 0, MAX_TIMEOUT_SECONDS); + b.topRepeat = boolValue(configuration, "notification_top_repeat"); + b.topPeriodSeconds = intValue(configuration, "notification_top_period", 0, MAX_TOP_PERIOD_SECONDS); + b.topFrequency = intValue(configuration, "notification_top_frequency", 0, MAX_TOP_FREQUENCY); + b.ongoing = boolValue(configuration, "notification_ongoing"); + b.colorized = boolValue(configuration, "notification_colorized"); + String background = first(configuration, "background_color", "notification_background_color"); + b.backgroundColor = colorValue(background); + b.visibility = visibility(configuration); + b.category = category(configuration); + return b.build(); + } + + @Nullable + private static Boolean boolValue(CustomConfiguration c, String key) { + if (!c.keys().contains(key)) return null; + String value = c.get(key, null); + if (value == null) return Boolean.FALSE; + if ("true".equalsIgnoreCase(value) || "1".equals(value)) return Boolean.TRUE; + if ("false".equalsIgnoreCase(value) || "0".equals(value)) return Boolean.FALSE; + return null; + } + + @Nullable + private static Integer intValue(CustomConfiguration c, String key, int min, int max) { + String value = c.get(key, null); + if (value == null) return null; + try { + int parsed = Integer.parseInt(value.trim()); + return parsed >= min && parsed <= max ? parsed : null; + } catch (NumberFormatException ignored) { + return null; + } + } + + @Nullable + private static String first(CustomConfiguration c, String first, String second) { + String value = c.get(first, null); + return value == null ? c.get(second, null) : value; + } + + @Nullable + private static Integer visibility(CustomConfiguration c) { + String value = first(c, "notification_visibility", "visibility"); + if (value == null) return null; + String normalized = value.trim().toLowerCase(Locale.ROOT); + if ("public".equals(normalized)) return 1; + if ("private".equals(normalized)) return 0; + if ("secret".equals(normalized)) return -1; + try { + int parsed = Integer.parseInt(normalized); + return parsed >= -1 && parsed <= 1 ? parsed : null; + } catch (NumberFormatException ignored) { + return null; + } + } + + @Nullable + private static String category(CustomConfiguration c) { + String value = first(c, "notification_category", "category"); + if (value == null) return null; + String normalized = value.trim().toLowerCase(Locale.ROOT); + // Android's documented categories. Unknown categories are not forwarded + // because HyperOS may treat arbitrary values as privileged hints. + switch (normalized) { + case "call": case "navigation": case "message": case "email": + case "event": case "alarm": case "progress": case "promo": + case "recommendation": case "service": case "social": + case "status": case "system": case "transport": case "err": + case "reminder": case "workout": case "location": + case "stopwatch": case "missed_call": + return normalized; + default: return null; + } + } + + @Nullable + private static Integer colorValue(@Nullable String value) { + if (value == null) return null; + String v = value.trim(); + try { + if (v.matches("#[0-9a-fA-F]{6}")) { + return (int) (0xff000000L | Long.parseLong(v.substring(1), 16)); + } + if (v.matches("#[0-9a-fA-F]{8}")) { + return (int) Long.parseLong(v.substring(1), 16); + } + // Xiaomi's published contract serialises the Android colour int as + // a decimal string (including negative values for opaque colours). + return Integer.parseInt(v); + } catch (NumberFormatException ignored) { + return null; + } + } + + @Nullable + private static String boundedText(@Nullable String value, int maxLength) { + if (value == null) return null; + String trimmed = value.trim(); + return trimmed.length() == 0 || trimmed.length() > maxLength ? null : trimmed; + } + + private static final class Builder { + Integer timeoutSeconds, foldTimeoutSeconds, topPeriodSeconds, topFrequency, visibility; + Boolean enableKeyguard, enableFloat, topRepeat, ongoing, colorized; + Integer backgroundColor; + String fold, category; + NotificationMetadata build() { return new NotificationMetadata(this); } + } +} diff --git a/common/src/main/java/top/trumeet/common/widget/LinkAlertDialog.java b/common/src/main/java/top/trumeet/common/widget/LinkAlertDialog.java index 29125df09..e77f9e284 100644 --- a/common/src/main/java/top/trumeet/common/widget/LinkAlertDialog.java +++ b/common/src/main/java/top/trumeet/common/widget/LinkAlertDialog.java @@ -2,12 +2,11 @@ import android.content.Context; import android.text.method.LinkMovementMethod; +import android.util.TypedValue; import android.widget.TextView; import androidx.appcompat.app.AlertDialog; -import top.trumeet.common.R; - /** * Created by Trumeet on 2017/12/30. */ @@ -43,8 +42,10 @@ public Builder(Context context, int themeResId) { @Override public Builder setMessage(CharSequence message) { TextView textView = new TextView(getContext()); - int padding = (int) getContext().getResources() - .getDimension(R.dimen.abc_dialog_padding_material); + int padding = (int) TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + 24, + getContext().getResources().getDisplayMetrics()); textView.setPadding(padding, padding, padding, padding); textView.setMovementMethod(LinkMovementMethod.getInstance()); textView.setText(message); diff --git a/common/src/test/java/test/top/trumeet/common/utils/utils/AlarmSchedulePolicyTest.java b/common/src/test/java/test/top/trumeet/common/utils/utils/AlarmSchedulePolicyTest.java new file mode 100644 index 000000000..255b11719 --- /dev/null +++ b/common/src/test/java/test/top/trumeet/common/utils/utils/AlarmSchedulePolicyTest.java @@ -0,0 +1,56 @@ +package test.top.trumeet.common.utils.utils; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; + +import top.trumeet.common.utils.AlarmSchedulePolicy; +import top.trumeet.common.utils.AlarmSchedulePolicy.AlarmScheduleType; + +public class AlarmSchedulePolicyTest { + + @Test + public void api34ExactAlarmAllowedReturnsExact() { + AlarmScheduleType type = AlarmSchedulePolicy.determineScheduleType(34, true); + assertEquals(AlarmScheduleType.EXACT, type); + } + + @Test + public void api34ExactAlarmNotAllowedReturnsInexactAllowWhileIdle() { + AlarmScheduleType type = AlarmSchedulePolicy.determineScheduleType(34, false); + assertEquals(AlarmScheduleType.INEXACT_ALLOW_WHILE_IDLE, type); + } + + @Test + public void api31ExactAlarmAllowedReturnsExact() { + AlarmScheduleType type = AlarmSchedulePolicy.determineScheduleType(31, true); + assertEquals(AlarmScheduleType.EXACT, type); + } + + @Test + public void powerSaveModeUsesInexactAlarmEvenWhenExactIsAllowed() { + AlarmScheduleType type = AlarmSchedulePolicy.determineScheduleType(34, true, true); + assertEquals(AlarmScheduleType.INEXACT_ALLOW_WHILE_IDLE, type); + } + + @Test + public void legacyPowerSaveModeUsesIdleAwareInexactAlarm() { + AlarmScheduleType type = AlarmSchedulePolicy.determineScheduleType(30, true, true); + assertEquals(AlarmScheduleType.INEXACT_ALLOW_WHILE_IDLE, type); + } + + @Test + public void api31ExactAlarmNotAllowedReturnsInexactAllowWhileIdle() { + AlarmScheduleType type = AlarmSchedulePolicy.determineScheduleType(31, false); + assertEquals(AlarmScheduleType.INEXACT_ALLOW_WHILE_IDLE, type); + } + + @Test + public void legacyApiReturnsExact() { + AlarmScheduleType type = AlarmSchedulePolicy.determineScheduleType(30, false); + assertEquals(AlarmScheduleType.EXACT, type); + + AlarmScheduleType type2 = AlarmSchedulePolicy.determineScheduleType(26, false); + assertEquals(AlarmScheduleType.EXACT, type2); + } +} diff --git a/common/src/test/java/test/top/trumeet/common/utils/utils/CustomConfigurationTest.java b/common/src/test/java/test/top/trumeet/common/utils/utils/CustomConfigurationTest.java index 47d2aaaf4..242b741a5 100644 --- a/common/src/test/java/test/top/trumeet/common/utils/utils/CustomConfigurationTest.java +++ b/common/src/test/java/test/top/trumeet/common/utils/utils/CustomConfigurationTest.java @@ -1,12 +1,20 @@ package test.top.trumeet.common.utils.utils; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; import org.junit.Test; +import java.util.ArrayList; +import java.util.Arrays; import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; import top.trumeet.common.utils.CustomConfiguration; +import top.trumeet.common.utils.NotificationAlertUtils; public class CustomConfigurationTest { @@ -18,4 +26,367 @@ public void textIcon() { assertEquals("qwe", custom.textIcon(null)); } -} \ No newline at end of file + + @Test + public void focusPayloadForwardsAllPicturesButCapsNativeDownloads() { + Map extras = new LinkedHashMap<>(); + extras.put("miui.focus.param", "{\"ticker\":\"parcel\"}"); + extras.put("miui.focus.pic_http", "http://example.com/not-allowed.png"); + extras.put("miui.focus.pic_malformed", "https://bad host/image.png"); + for (int i : new int[]{11, 2, 7, 10, 1, 9, 0, 5, 3, 8, 6, 4}) { + extras.put("miui.focus.pic_" + i, "https://example.com/" + i + ".png"); + } + + CustomConfiguration.FocusNotificationPayload payload = + new CustomConfiguration(extras).focusNotificationPayload(); + + assertTrue(payload.isUsable()); + assertEquals("{\"ticker\":\"parcel\"}", payload.parameter()); + assertEquals(14, + payload.pictureUrls().size()); + assertEquals("https://example.com/0.png", + payload.pictureUrls().get("miui.focus.pic_0")); + assertEquals(Arrays.asList( + "miui.focus.pic_0", "miui.focus.pic_1", "miui.focus.pic_2", + "miui.focus.pic_3", "miui.focus.pic_4", "miui.focus.pic_5", + "miui.focus.pic_6", "miui.focus.pic_7", "miui.focus.pic_8", + "miui.focus.pic_9"), + new ArrayList<>(payload.downloadPictureUrls().keySet())); + assertTrue(payload.pictureUrls().containsKey("miui.focus.pic_http")); + assertTrue(payload.pictureUrls().containsKey("miui.focus.pic_malformed")); + assertFalse(payload.downloadPictureUrls().containsKey("miui.focus.pic_http")); + assertFalse(payload.downloadPictureUrls().containsKey("miui.focus.pic_malformed")); + assertTrue(payload.pictureUrls().containsKey("miui.focus.pic_10")); + assertTrue(payload.pictureUrls().containsKey("miui.focus.pic_11")); + } + + @Test + public void focusPayloadForwardsUnsafeUrlsButRejectsThemForNativeDownloads() { + Map extras = new LinkedHashMap<>(); + extras.put("miui.focus.param", "{\"ticker\":\"url-tests\"}"); + extras.put("miui.focus.pic_1", "https://user:pass@example.com/pic.png"); + extras.put("miui.focus.pic_2", "https:///pic.png"); + extras.put("miui.focus.pic_3", "https://?query=1"); + extras.put("miui.focus.pic_4", "https://example .com/pic.png"); + extras.put("miui.focus.pic_5", "https://example\t.com/pic.png"); + extras.put("miui.focus.pic_6", "https://valid.example.com/image.png"); + + CustomConfiguration.FocusNotificationPayload payload = + new CustomConfiguration(extras).focusNotificationPayload(); + + assertTrue(payload.isUsable()); + assertEquals(6, payload.pictureUrls().size()); + assertEquals(1, payload.downloadPictureUrls().size()); + assertTrue(payload.pictureUrls().containsKey("miui.focus.pic_6")); + assertEquals("https://valid.example.com/image.png", payload.pictureUrls().get("miui.focus.pic_6")); + assertTrue(payload.pictureUrls().containsKey("miui.focus.pic_1")); + assertTrue(payload.pictureUrls().containsKey("miui.focus.pic_2")); + assertTrue(payload.pictureUrls().containsKey("miui.focus.pic_3")); + assertTrue(payload.pictureUrls().containsKey("miui.focus.pic_4")); + assertTrue(payload.pictureUrls().containsKey("miui.focus.pic_5")); + assertFalse(payload.downloadPictureUrls().containsKey("miui.focus.pic_1")); + assertFalse(payload.downloadPictureUrls().containsKey("miui.focus.pic_2")); + assertFalse(payload.downloadPictureUrls().containsKey("miui.focus.pic_3")); + assertFalse(payload.downloadPictureUrls().containsKey("miui.focus.pic_4")); + assertFalse(payload.downloadPictureUrls().containsKey("miui.focus.pic_5")); + } + + @Test + public void focusPayloadAcceptsPlatformResourceUris() { + Map extras = new LinkedHashMap<>(); + extras.put("miui.focus.pic_content", "content://com.example.app/image/1"); + extras.put("miui.focus.pic_resource", "android.resource://com.example.app/drawable/icon"); + + CustomConfiguration.FocusNotificationPayload payload = + new CustomConfiguration(extras).focusNotificationPayload(); + + assertTrue(payload.isUsable()); + assertEquals(2, payload.pictureUrls().size()); + assertEquals(2, payload.downloadPictureUrls().size()); + } + + @Test + public void focusPicturesCanBeUsedWithoutParameterLikeOfficialClient() { + Map extras = new HashMap<>(); + extras.put("miui.focus.pic_0", "content://com.example.app/image/1"); + + CustomConfiguration.FocusNotificationPayload payload = + new CustomConfiguration(extras).focusNotificationPayload(); + + assertTrue(payload.isUsable()); + } + + @Test + public void focusPayloadAcceptsParameterWithoutPictures() { + Map extras = new HashMap<>(); + extras.put("miui.focus.param", "{\"ticker\":\"text-only\"}"); + + CustomConfiguration.FocusNotificationPayload payload = + new CustomConfiguration(extras).focusNotificationPayload(); + + assertTrue(payload.isUsable()); + assertTrue(payload.pictureUrls().isEmpty()); + } + + @Test + public void focusPayloadCapturesBoundedCustomParameterWithoutParcelableGuessing() { + Map extras = new HashMap<>(); + String custom = "{\"business\":\"tsmclient\",\"param_island\":{" + + "\"islandTimeout\":3}}"; + extras.put("miui.focus.param.custom", custom); + + CustomConfiguration.FocusNotificationPayload payload = + new CustomConfiguration(extras).focusNotificationPayload(); + + assertTrue(payload.isUsable()); + assertEquals(custom, payload.customParameter()); + assertNull(payload.parameter()); + assertTrue(payload.pictureUrls().isEmpty()); + } + + @Test + public void focusPayloadRejectsMalformedOrOversizedCustomParameter() { + Map extras = new HashMap<>(); + extras.put("miui.focus.param.custom", "not-json"); + + CustomConfiguration.FocusNotificationPayload malformed = + new CustomConfiguration(extras).focusNotificationPayload(); + assertNull(malformed.customParameter()); + assertFalse(malformed.isUsable()); + + extras.put("miui.focus.param.custom", "{not-json}"); + CustomConfiguration.FocusNotificationPayload malformedObject = + new CustomConfiguration(extras).focusNotificationPayload(); + // The common module rejects obvious scalar values, while the push + // module's full parser rejects this syntactically invalid object. + assertEquals("{not-json}", malformedObject.customParameter()); + assertTrue(malformedObject.isUsable()); + + StringBuilder oversized = new StringBuilder("{"); + while (oversized.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8).length + <= CustomConfiguration.FOCUS_PARAM_MAX_BYTES) { + oversized.append('x'); + } + oversized.append('}'); + extras.put("miui.focus.param.custom", oversized.toString()); + CustomConfiguration.FocusNotificationPayload rejected = + new CustomConfiguration(extras).focusNotificationPayload(); + assertNull(rejected.customParameter()); + assertFalse(rejected.isUsable()); + } + + @Test + public void blankFocusParameterNeedsAtLeastOnePicture() { + Map extras = new HashMap<>(); + extras.put("miui.focus.param", " "); + + assertFalse(new CustomConfiguration(extras) + .focusNotificationPayload().isUsable()); + + extras.put("miui.focus.pic_0", "https://example.com/focus.png"); + assertTrue(new CustomConfiguration(extras) + .focusNotificationPayload().isUsable()); + } + + @Test + public void focusPayloadRejectsParameterOverUtf8ByteLimit() { + StringBuilder oversized = new StringBuilder(); + while (oversized.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8).length + <= CustomConfiguration.FOCUS_PARAM_MAX_BYTES) { + oversized.append('\u754c'); + } + Map extras = new HashMap<>(); + extras.put("miui.focus.param", oversized.toString()); + + CustomConfiguration.FocusNotificationPayload payload = + new CustomConfiguration(extras).focusNotificationPayload(); + + assertNull(payload.parameter()); + assertFalse(payload.isUsable()); + } + + @Test + public void focusLimitsIncludeTheirPublishedBoundary() { + String exactParameter = repeat('a', CustomConfiguration.FOCUS_PARAM_MAX_BYTES); + String oversizedParameter = exactParameter + "a"; + + assertTrue(CustomConfiguration.FocusNotificationPayload + .isParameterWithinLimit(exactParameter)); + assertFalse(CustomConfiguration.FocusNotificationPayload + .isParameterWithinLimit(oversizedParameter)); + assertTrue(CustomConfiguration.FocusNotificationPayload + .isPictureSizeAllowed(CustomConfiguration.FOCUS_PICTURE_MAX_BYTES)); + assertFalse(CustomConfiguration.FocusNotificationPayload + .isPictureSizeAllowed(CustomConfiguration.FOCUS_PICTURE_MAX_BYTES + 1L)); + assertFalse(CustomConfiguration.FocusNotificationPayload + .isPictureSizeAllowed(-1)); + } + + private static String repeat(char value, int count) { + StringBuilder result = new StringBuilder(count); + for (int i = 0; i < count; i++) { + result.append(value); + } + return result.toString(); + } + + @Test + public void focusProtocolAcceptsEveryPositiveVersionLikeOfficialClient() { + assertFalse(CustomConfiguration.FocusNotificationPayload + .isSupportedProtocolVersion(0)); + assertTrue(CustomConfiguration.FocusNotificationPayload + .isSupportedProtocolVersion(1)); + assertTrue(CustomConfiguration.FocusNotificationPayload + .isSupportedProtocolVersion(3)); + assertTrue(CustomConfiguration.FocusNotificationPayload + .isSupportedProtocolVersion(4)); + assertTrue(CustomConfiguration.FocusNotificationPayload + .isSupportedProtocolVersion(99)); + assertFalse(CustomConfiguration.FocusNotificationPayload + .isSupportedProtocolVersion(-1)); + } + + @Test + public void channelFieldsUseOfficialNamesWithLegacySoundFallback() { + Map extras = new HashMap<>(); + extras.put("channel_importance", "4"); + extras.put("sound_url", "content://legacy"); + CustomConfiguration custom = new CustomConfiguration(extras); + + assertEquals(4, custom.channelImportance(3)); + assertEquals("content://legacy", custom.soundUri(null)); + + extras.put("sound_uri", "content://official"); + assertEquals("content://official", custom.soundUri(null)); + extras.put("channel_importance", "99"); + assertEquals(3, custom.channelImportance(3)); + } + + @Test + public void notificationShowWhenParsesValueInsteadOfPresence() { + Map extras = new HashMap<>(); + extras.put("notification_show_when", "false"); + CustomConfiguration custom = new CustomConfiguration(extras); + + assertFalse(custom.notificationShowWhen(true)); + extras.put("notification_show_when", "true"); + assertTrue(custom.notificationShowWhen(false)); + extras.remove("notification_show_when"); + assertTrue(custom.notificationShowWhen(true)); + } + + @Test + public void useClickedActivityParsesStrictBooleanValue() { + Map extras = new HashMap<>(); + CustomConfiguration custom = new CustomConfiguration(extras); + + // Production callers pass false as the default; an absent key must not + // opt every notification into an Activity PendingIntent. + assertFalse(custom.useClickedActivity(false)); + + extras.put("use_clicked_activity", "true"); + assertTrue(custom.useClickedActivity(false)); + + extras.put("use_clicked_activity", "TRUE"); + assertTrue(custom.useClickedActivity(false)); + + extras.put("use_clicked_activity", "false"); + assertFalse(custom.useClickedActivity(false)); + + extras.put("use_clicked_activity", ""); + assertFalse(custom.useClickedActivity(false)); + + extras.put("use_clicked_activity", "garbage"); + assertFalse(custom.useClickedActivity(false)); + + extras.put("use_clicked_activity", null); + assertFalse(custom.useClickedActivity(false)); + } + + @Test + public void officialHyperOsNotificationMetadataUsesPublishedKeys() { + Map extras = new HashMap<>(); + extras.put("notification_style_type", "4"); + extras.put("notification_timeout", "30"); + extras.put("notification_small_icon_uri", "content://com.example/icon"); + extras.put("enable_keyguard", "false"); + extras.put("miui.fold.timeout", "12"); + + CustomConfiguration custom = new CustomConfiguration(extras); + + assertEquals("4", custom.notificationStyleType(null)); + assertEquals(30, custom.notificationTimeoutSeconds(0)); + assertEquals("content://com.example/icon", custom.notificationSmallIconUri(null)); + assertFalse(custom.enableKeyguard(true)); + assertEquals(12, custom.miuiFoldTimeoutSeconds(0)); + } + + @Test + public void notificationStyleMappingMatchesOfficialXmsf() { + Map extras = new HashMap<>(); + CustomConfiguration custom = new CustomConfiguration(extras); + + extras.put("notification_style_type", "3"); + assertEquals(CustomConfiguration.NotificationStyle.COLORFUL, + custom.notificationStyle()); + + extras.put("notification_style_type", "4"); + assertEquals(CustomConfiguration.NotificationStyle.BANNER, + custom.notificationStyle()); + + extras.put("notification_style_type", "unknown"); + assertEquals(CustomConfiguration.NotificationStyle.DEFAULT, + custom.notificationStyle()); + } + + @Test + public void officialColorfulBackgroundKeysWinOverLegacyImageFallback() { + Map extras = new HashMap<>(); + extras.put("notification_colorful_button_bg_image_uri", "content://legacy/image"); + extras.put("notification_colorful_button_bg_color", "#111111"); + extras.put("notification_colorful_bg_color", "#222222"); + CustomConfiguration custom = new CustomConfiguration(extras); + + assertEquals("content://legacy/image", + custom.notificationColorfulBackgroundImageUri(null)); + assertEquals("#222222", custom.notificationColorfulBackgroundColor(null)); + assertEquals("#111111", custom.notificationColorfulButtonBackgroundColor(null)); + + extras.remove("notification_colorful_bg_color"); + assertNull(custom.notificationColorfulBackgroundColor(null)); + extras.put("notification_colorful_bg_color", "#222222"); + + extras.put("notification_colorful_bg_image_uri", "content://official/image"); + assertEquals("content://official/image", + custom.notificationColorfulBackgroundImageUri(null)); + assertEquals("content://official/image", + custom.notificationColorfulButtonBackgroundImageUri(null)); + + extras.put("notification_colorful_bg_image_uri", ""); + assertEquals("", custom.notificationColorfulBackgroundImageUri(null)); + } + + @Test + public void resourceSoundRequiresSoundBitAndMatchingPackage() { + String packageName = "com.example.app"; + String soundUri = "android.resource://com.example.app/raw/ping"; + + assertTrue(NotificationAlertUtils.usesPackageResourceSound( + NotificationAlertUtils.NOTIFY_TYPE_SOUND, soundUri, packageName)); + assertTrue(NotificationAlertUtils.usesPackageResourceSound( + NotificationAlertUtils.NOTIFY_TYPE_SOUND + | NotificationAlertUtils.NOTIFY_TYPE_VIBRATE + | NotificationAlertUtils.NOTIFY_TYPE_LIGHTS, + soundUri, packageName)); + assertFalse(NotificationAlertUtils.usesPackageResourceSound( + NotificationAlertUtils.NOTIFY_TYPE_VIBRATE, soundUri, packageName)); + assertFalse(NotificationAlertUtils.usesPackageResourceSound( + NotificationAlertUtils.NOTIFY_TYPE_SOUND, + "android.resource://com.other.app/raw/ping", packageName)); + assertFalse(NotificationAlertUtils.usesPackageResourceSound( + NotificationAlertUtils.NOTIFY_TYPE_SOUND, + "content://com.example.app/ping", packageName)); + assertFalse(NotificationAlertUtils.usesPackageResourceSound( + NotificationAlertUtils.NOTIFY_TYPE_SOUND, null, packageName)); + } +} diff --git a/common/src/test/java/test/top/trumeet/common/utils/utils/DeviceFocusPolicyTest.java b/common/src/test/java/test/top/trumeet/common/utils/utils/DeviceFocusPolicyTest.java new file mode 100644 index 000000000..ba4ccc151 --- /dev/null +++ b/common/src/test/java/test/top/trumeet/common/utils/utils/DeviceFocusPolicyTest.java @@ -0,0 +1,42 @@ +package test.top.trumeet.common.utils.utils; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import top.trumeet.common.utils.DeviceFocusPolicy; + +public class DeviceFocusPolicyTest { + @Test + public void hyperOsSystemUiUsesSystemRenderer() { + assertEquals(DeviceFocusPolicy.Renderer.SYSTEM, + DeviceFocusPolicy.rendererFor("miui.systemui.plugin", "Xiaomi", 3)); + assertEquals(DeviceFocusPolicy.Renderer.SYSTEM, + DeviceFocusPolicy.rendererFor("com.android.systemui", "redmi", 1)); + } + + @Test + public void nonXiaomiOrMissingProtocolUsesPortableRenderer() { + assertEquals(DeviceFocusPolicy.Renderer.PORTABLE, + DeviceFocusPolicy.rendererFor("com.android.systemui", "Google", 3)); + assertEquals(DeviceFocusPolicy.Renderer.PORTABLE, + DeviceFocusPolicy.rendererFor("miui.systemui.plugin", "Google", 3)); + assertEquals(DeviceFocusPolicy.Renderer.PORTABLE, + DeviceFocusPolicy.rendererFor("com.android.systemui", "Xiaomi", 0)); + assertEquals(DeviceFocusPolicy.Renderer.PORTABLE, + DeviceFocusPolicy.rendererFor("com.google.android.systemui", "Xiaomi", 3)); + assertEquals(DeviceFocusPolicy.Renderer.PORTABLE, + DeviceFocusPolicy.rendererFor(null, null, 3)); + } + + @Test + public void packageAttributionRequiresXiaomiHardwareIdentity() { + assertTrue(DeviceFocusPolicy.isXiaomiManufacturer("Xiaomi")); + assertTrue(DeviceFocusPolicy.isXiaomiManufacturer("POCO")); + assertFalse(DeviceFocusPolicy.isXiaomiManufacturer("Sony")); + assertFalse(DeviceFocusPolicy.isXiaomiManufacturer("Google")); + assertFalse(DeviceFocusPolicy.isXiaomiManufacturer(null)); + } +} diff --git a/common/src/test/java/test/top/trumeet/common/utils/utils/NotificationMetadataTest.java b/common/src/test/java/test/top/trumeet/common/utils/utils/NotificationMetadataTest.java new file mode 100644 index 000000000..ec2834ba7 --- /dev/null +++ b/common/src/test/java/test/top/trumeet/common/utils/utils/NotificationMetadataTest.java @@ -0,0 +1,76 @@ +package test.top.trumeet.common.utils.utils; + +import org.junit.Test; + +import java.util.HashMap; +import java.util.Map; + +import top.trumeet.common.utils.CustomConfiguration; +import top.trumeet.common.utils.NotificationMetadata; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class NotificationMetadataTest { + @Test + public void parsesHyperOsFieldsAndAliases() { + Map values = new HashMap<>(); + values.put("timeout", "30"); + values.put("enable_keyguard", "false"); + values.put("enable_float", "1"); + values.put("notification_fold", "true"); + values.put("miui.fold.timeout", "12"); + values.put("notification_top_repeat", "true"); + values.put("notification_top_period", "3600"); + values.put("notification_top_frequency", "4"); + values.put("notification_ongoing", "false"); + values.put("notification_colorized", "true"); + values.put("background_color", "#112233"); + values.put("visibility", "secret"); + values.put("category", "message"); + + NotificationMetadata metadata = NotificationMetadata.from(new CustomConfiguration(values)); + assertEquals(Integer.valueOf(30), metadata.timeoutSeconds); + assertEquals(Boolean.FALSE, metadata.enableKeyguard); + assertEquals(Boolean.TRUE, metadata.enableFloat); + assertEquals(Boolean.TRUE, metadata.topRepeat); + assertEquals("true", metadata.fold); + assertEquals(Integer.valueOf(12), metadata.foldTimeoutSeconds); + assertEquals(Integer.valueOf(3600), metadata.topPeriodSeconds); + assertEquals(Integer.valueOf(4), metadata.topFrequency); + assertEquals(Boolean.FALSE, metadata.ongoing); + assertEquals(Boolean.TRUE, metadata.colorized); + assertEquals(Integer.valueOf(-1), metadata.visibility); + assertEquals("message", metadata.category); + assertEquals(Integer.valueOf(0xff112233), metadata.backgroundColor); + } + + @Test + public void acceptsDocumentedModernAndroidCategories() { + Map values = new HashMap<>(); + values.put("notification_category", "missed_call"); + + NotificationMetadata metadata = NotificationMetadata.from(new CustomConfiguration(values)); + + assertEquals("missed_call", metadata.category); + } + + @Test + public void rejectsOutOfRangeAndUnsafeHints() { + Map values = new HashMap<>(); + values.put("notification_timeout", "999999999"); + values.put("notification_top_repeat", "-1"); + values.put("notification_visibility", "admin"); + values.put("notification_category", "vendor-private"); + values.put("background_color", "red"); + + NotificationMetadata metadata = NotificationMetadata.from(new CustomConfiguration(values)); + assertNull(metadata.timeoutSeconds); + assertNull(metadata.topRepeat); + assertNull(metadata.visibility); + assertNull(metadata.category); + assertNull(metadata.backgroundColor); + assertTrue(NotificationMetadata.MAX_TIMEOUT_SECONDS > 0); + } +} diff --git a/condom/build.gradle b/condom/build.gradle index 2c7aa4e8e..573d95345 100644 --- a/condom/build.gradle +++ b/condom/build.gradle @@ -25,6 +25,9 @@ android { consumerProguardFiles 'proguard-rules.pro' } } + buildFeatures { + buildConfig true + } namespace 'com.oasisfeng.condom' } @@ -48,7 +51,7 @@ android.libraryVariants.all { variant -> task sourcesJar(type: Jar) { from android.sourceSets.main.java.srcDirs - classifier = 'sources' + archiveClassifier = 'sources' } artifacts { diff --git a/gradle.properties b/gradle.properties index 4284779c6..b089297fe 100644 --- a/gradle.properties +++ b/gradle.properties @@ -15,4 +15,7 @@ #Mon Apr 16 11:20:34 CST 2018 org.gradle.jvmargs=-Xmx1536m android.enableJetifier=true -android.useAndroidX=true \ No newline at end of file +android.useAndroidX=true +# The legacy app references shared common-library resources through the app R class. +# Keep transitive resource symbols while the modules are being migrated to AGP 8. +android.nonTransitiveRClass=false diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index b5d3cc1f8..257a551d2 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Sun Feb 05 23:39:38 CST 2023 distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-7.6-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip distributionPath=wrapper/dists zipStorePath=wrapper/dists zipStoreBase=GRADLE_USER_HOME diff --git a/mipush_hook/build.gradle.kts b/mipush_hook/build.gradle.kts index ae610ce2f..2edeef0eb 100644 --- a/mipush_hook/build.gradle.kts +++ b/mipush_hook/build.gradle.kts @@ -1,7 +1,9 @@ +import io.freefair.gradle.plugins.aspectj.AspectjCompile + plugins { id("com.android.library") id("org.jetbrains.kotlin.android") - id("io.github.wurensen.android-aspectjx") + id("io.freefair.android.aspectj.post-compile-weaving") } val mipushLib = file("libs/miuipushsdkshared_3_7_9.jar") @@ -9,10 +11,10 @@ extra["mipushLib"] = mipushLib android { namespace = "com.nihility.mipush_hook" - compileSdk = 33 + compileSdk = 34 defaultConfig { - minSdk = 21 + minSdk = 26 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles("consumer-rules.pro") @@ -32,24 +34,33 @@ android { jvmTarget = "11" } - aspectjx { - // 移除kotlin相关,编译错误和提升速度 - exclude("kotlin.jvm", "kotlin.internal") - exclude("kotlinx.coroutines.internal", "kotlinx.coroutines.android") - exclude("test.", "Test") - ajcArgs("-inpath", mipushLib.path) - debug = false - } +} + +val aspectjTools by configurations.creating + +tasks.withType().configureEach { + aspectjClasspath.from(aspectjTools) + // The legacy Xiaomi SDK is the only external inpath. Keep Android APIs on + // ajc's boot class path instead of treating the entire dependency graph as + // weave input. + ajcOptions.bootclasspath.from(android.bootClasspath) + ajcOptions.compilerArgs = listOf("-Xlint:ignore") } dependencies { + inpath(files(mipushLib)) compileOnly(files(mipushLib)) + implementation("org.aspectj:aspectjrt:1.9.22.1") + // Keep the runtime jar visible to ajc's type resolver as well as the APK. + aspectjTools("org.aspectj:aspectjrt:1.9.22.1") + aspectjTools("org.aspectj:aspectjtools:1.9.22.1") implementation("androidx.startup:startup-runtime:1.1.1") implementation("androidx.core:core-ktx:1.10.1") implementation("androidx.appcompat:appcompat:1.6.1") implementation("com.google.android.material:material:1.8.0") + implementation(project(":common")) testImplementation("junit:junit:4.13.2") androidTestImplementation("androidx.test.ext:junit:1.1.5") androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") -} \ No newline at end of file +} diff --git a/mipush_hook/src/main/java/com/xiaomi/push/service/timers/AlarmManagerTimerAspect.java b/mipush_hook/src/main/java/com/xiaomi/push/service/timers/AlarmManagerTimerAspect.java new file mode 100644 index 000000000..8ab72dedd --- /dev/null +++ b/mipush_hook/src/main/java/com/xiaomi/push/service/timers/AlarmManagerTimerAspect.java @@ -0,0 +1,134 @@ +package com.xiaomi.push.service.timers; + +import android.app.AlarmManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.os.Build; +import android.os.PowerManager; + +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; + +import java.lang.reflect.Field; + +import top.trumeet.common.utils.AlarmSchedulePolicy.AlarmScheduleType; + +@Aspect +public class AlarmManagerTimerAspect { + + @Around("execution(* com.xiaomi.push.service.timers.AlarmManagerTimer.register(..)) && this(timer) && args(intent, deadlineMs)") + public void aroundRegister(ProceedingJoinPoint joinPoint, Object timer, Intent intent, long deadlineMs) throws Throwable { + try { + Context context = getContext(timer); + if (context == null) { + joinPoint.proceed(); + return; + } + + AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); + if (alarmManager == null) { + joinPoint.proceed(); + return; + } + + boolean canScheduleExact = true; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + canScheduleExact = alarmManager.canScheduleExactAlarms(); + } + boolean powerSaveMode = false; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + try { + PowerManager powerManager = + (PowerManager) context.getSystemService(Context.POWER_SERVICE); + powerSaveMode = powerManager != null && powerManager.isPowerSaveMode(); + } catch (Throwable ignored) { + // A missing/denied power-state probe must not block scheduling. + } + } + + AlarmManagerTimerSchedulePolicy.Schedule schedule = + AlarmManagerTimerSchedulePolicy.forWallClockDeadline( + Build.VERSION.SDK_INT, canScheduleExact, powerSaveMode, deadlineMs); + + int flags = PendingIntent.FLAG_UPDATE_CURRENT; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + flags |= PendingIntent.FLAG_IMMUTABLE; + } + + PendingIntent pi = PendingIntent.getBroadcast(context, 0, intent, flags); + long triggerAtMillis = schedule.getTriggerAtMillis(); + int alarmType = toAndroidAlarmType(schedule.getClockType()); + + if (schedule.getScheduleType() == AlarmScheduleType.EXACT) { + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + alarmManager.setExactAndAllowWhileIdle(alarmType, triggerAtMillis, pi); + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + alarmManager.setExact(alarmType, triggerAtMillis, pi); + } else { + alarmManager.set(alarmType, triggerAtMillis, pi); + } + } catch (SecurityException se) { + // Fallback to inexact allow while idle on SecurityException + scheduleInexact(alarmManager, alarmType, triggerAtMillis, pi); + } + } else { + scheduleInexact(alarmManager, alarmType, triggerAtMillis, pi); + } + + setField(timer, "mPi", pi); + setField(timer, "mNextPingTs", schedule.getNextPingTimestampMillis()); + } catch (Throwable e) { + joinPoint.proceed(); + } + } + + private static int toAndroidAlarmType(AlarmManagerTimerSchedulePolicy.ClockType clockType) { + if (clockType != AlarmManagerTimerSchedulePolicy.ClockType.RTC_WAKEUP) { + throw new IllegalArgumentException("Unsupported alarm clock type: " + clockType); + } + return AlarmManager.RTC_WAKEUP; + } + + private static void scheduleInexact( + AlarmManager alarmManager, int alarmType, long triggerAtMillis, PendingIntent pi) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + alarmManager.setAndAllowWhileIdle(alarmType, triggerAtMillis, pi); + } else { + alarmManager.set(alarmType, triggerAtMillis, pi); + } + } + + public static Context getContext(Object timer) { + try { + Field field = timer.getClass().getDeclaredField("mContext"); + field.setAccessible(true); + return (Context) field.get(timer); + } catch (Throwable ignored) { + try { + Field field = timer.getClass().getSuperclass().getDeclaredField("mContext"); + field.setAccessible(true); + return (Context) field.get(timer); + } catch (Throwable ignored2) { + return null; + } + } + } + + public static void setField(Object target, String fieldName, Object value) { + try { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } catch (Throwable ignored) { + try { + Field field = target.getClass().getSuperclass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } catch (Throwable ignored2) { + } + } + } +} diff --git a/mipush_hook/src/main/java/com/xiaomi/push/service/timers/AlarmManagerTimerSchedulePolicy.java b/mipush_hook/src/main/java/com/xiaomi/push/service/timers/AlarmManagerTimerSchedulePolicy.java new file mode 100644 index 000000000..39c07a779 --- /dev/null +++ b/mipush_hook/src/main/java/com/xiaomi/push/service/timers/AlarmManagerTimerSchedulePolicy.java @@ -0,0 +1,74 @@ +package com.xiaomi.push.service.timers; + +import top.trumeet.common.utils.AlarmSchedulePolicy; +import top.trumeet.common.utils.AlarmSchedulePolicy.AlarmScheduleType; + +/** + * Defines the clock and deadline contract used when replacing Xiaomi's alarm timer. + * + *

{@code AlarmManagerTimer.register(Intent, long)} receives an absolute + * {@link System#currentTimeMillis()} deadline. It is not an elapsed-realtime delay. Exact-alarm + * permission changes scheduling precision only; it must never change the clock domain or deadline. + */ +final class AlarmManagerTimerSchedulePolicy { + + enum ClockType { + RTC_WAKEUP + } + + static final class Schedule { + private final AlarmScheduleType scheduleType; + private final ClockType clockType; + private final long triggerAtMillis; + private final long nextPingTimestampMillis; + + private Schedule( + AlarmScheduleType scheduleType, + ClockType clockType, + long triggerAtMillis, + long nextPingTimestampMillis) { + this.scheduleType = scheduleType; + this.clockType = clockType; + this.triggerAtMillis = triggerAtMillis; + this.nextPingTimestampMillis = nextPingTimestampMillis; + } + + AlarmScheduleType getScheduleType() { + return scheduleType; + } + + ClockType getClockType() { + return clockType; + } + + long getTriggerAtMillis() { + return triggerAtMillis; + } + + long getNextPingTimestampMillis() { + return nextPingTimestampMillis; + } + } + + private AlarmManagerTimerSchedulePolicy() { + } + + static Schedule forWallClockDeadline( + int sdkInt, boolean canScheduleExactAlarms, long wallClockDeadlineMs) { + return forWallClockDeadline( + sdkInt, canScheduleExactAlarms, false, wallClockDeadlineMs); + } + + static Schedule forWallClockDeadline( + int sdkInt, boolean canScheduleExactAlarms, boolean powerSaveMode, + long wallClockDeadlineMs) { + AlarmScheduleType scheduleType = + AlarmSchedulePolicy.determineScheduleType( + sdkInt, canScheduleExactAlarms, powerSaveMode); + return new Schedule( + scheduleType, + ClockType.RTC_WAKEUP, + wallClockDeadlineMs, + wallClockDeadlineMs); + } +} diff --git a/mipush_hook/src/test/java/com/xiaomi/push/service/timers/AlarmManagerTimerSchedulePolicyTest.java b/mipush_hook/src/test/java/com/xiaomi/push/service/timers/AlarmManagerTimerSchedulePolicyTest.java new file mode 100644 index 000000000..8e2157c76 --- /dev/null +++ b/mipush_hook/src/test/java/com/xiaomi/push/service/timers/AlarmManagerTimerSchedulePolicyTest.java @@ -0,0 +1,70 @@ +package com.xiaomi.push.service.timers; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; + +import top.trumeet.common.utils.AlarmSchedulePolicy.AlarmScheduleType; + +public class AlarmManagerTimerSchedulePolicyTest { + + private static final long WALL_CLOCK_DEADLINE_MS = 1_800_000_123_456L; + + @Test + public void exactSchedulePreservesRtcWakeupDeadlineAndNextPingTimestamp() { + AlarmManagerTimerSchedulePolicy.Schedule schedule = + AlarmManagerTimerSchedulePolicy.forWallClockDeadline( + 34, true, WALL_CLOCK_DEADLINE_MS); + + assertEquals(AlarmScheduleType.EXACT, schedule.getScheduleType()); + assertEquals( + AlarmManagerTimerSchedulePolicy.ClockType.RTC_WAKEUP, + schedule.getClockType()); + assertEquals(WALL_CLOCK_DEADLINE_MS, schedule.getTriggerAtMillis()); + assertEquals(WALL_CLOCK_DEADLINE_MS, schedule.getNextPingTimestampMillis()); + } + + @Test + public void inexactScheduleChangesOnlyPrecisionAndPreservesDeadlineContract() { + AlarmManagerTimerSchedulePolicy.Schedule schedule = + AlarmManagerTimerSchedulePolicy.forWallClockDeadline( + 34, false, WALL_CLOCK_DEADLINE_MS); + + assertEquals(AlarmScheduleType.INEXACT_ALLOW_WHILE_IDLE, schedule.getScheduleType()); + assertEquals( + AlarmManagerTimerSchedulePolicy.ClockType.RTC_WAKEUP, + schedule.getClockType()); + assertEquals(WALL_CLOCK_DEADLINE_MS, schedule.getTriggerAtMillis()); + assertEquals(WALL_CLOCK_DEADLINE_MS, schedule.getNextPingTimestampMillis()); + } + + @Test + public void powerSaveModeUsesInexactScheduleAndPreservesDeadline() { + AlarmManagerTimerSchedulePolicy.Schedule schedule = + AlarmManagerTimerSchedulePolicy.forWallClockDeadline( + 34, true, true, WALL_CLOCK_DEADLINE_MS); + + assertEquals(AlarmScheduleType.INEXACT_ALLOW_WHILE_IDLE, schedule.getScheduleType()); + assertEquals(WALL_CLOCK_DEADLINE_MS, schedule.getTriggerAtMillis()); + assertEquals(WALL_CLOCK_DEADLINE_MS, schedule.getNextPingTimestampMillis()); + } + + @Test + public void legacyPowerSaveModeUsesInexactSchedule() { + AlarmManagerTimerSchedulePolicy.Schedule schedule = + AlarmManagerTimerSchedulePolicy.forWallClockDeadline( + 30, true, true, WALL_CLOCK_DEADLINE_MS); + + assertEquals(AlarmScheduleType.INEXACT_ALLOW_WHILE_IDLE, schedule.getScheduleType()); + } + + @Test + public void expiredOrZeroDeadlineIsPassedThroughWithoutDelayConversionOrClamping() { + AlarmManagerTimerSchedulePolicy.Schedule expired = + AlarmManagerTimerSchedulePolicy.forWallClockDeadline(30, false, 0L); + + assertEquals(AlarmScheduleType.EXACT, expired.getScheduleType()); + assertEquals(0L, expired.getTriggerAtMillis()); + assertEquals(0L, expired.getNextPingTimestampMillis()); + } +} diff --git a/push/build.gradle b/push/build.gradle index 3355b2f18..5e4ee3025 100644 --- a/push/build.gradle +++ b/push/build.gradle @@ -1,9 +1,8 @@ apply plugin: 'com.android.application' apply plugin: 'kotlin-android' -apply plugin: 'io.github.wurensen.android-aspectjx' +apply plugin: 'org.jetbrains.kotlin.plugin.compose' apply plugin: 'org.greenrobot.greendao' - android { compileSdkVersion rootProject.ext.compileSdkVersion @@ -28,6 +27,61 @@ android { } buildConfigField "String", "GIT_TAG", "\"" + rootProject.ext.gitTag + "\"" + manifestPlaceholders = [ + mipushReceivePermission: "com.xiaomi.xmsf.permission.MIPUSH_RECEIVE" + ] + } + + signingConfigs { + debug { + v1SigningEnabled true + v2SigningEnabled true + enableV3Signing = true + enableV4Signing = true + } + // The legacy config name is kept for variant compatibility. The key is + // project-owned and can be supplied through CI or local properties. + nihility { + v1SigningEnabled true + v2SigningEnabled true + enableV3Signing = true + enableV4Signing = true + + def locale = System.getenv("MIPUSH_KEYSTORE_PATH") + def keystorePwd = System.getenv("MIPUSH_KEYSTORE_PASSWORD") ?: System.getenv("KEYSTORE_PASS") + def alias = System.getenv("MIPUSH_KEY_ALIAS") ?: System.getenv("ALIAS_NAME") + def pwd = System.getenv("MIPUSH_KEY_PASSWORD") ?: System.getenv("ALIAS_PASS") + if (locale == null) { + // Preserve the old local fallback for existing developer setups. + locale = project.rootProject.file(".yuuta.jks") + } + if (project.rootProject.file('local.properties').exists()) { + Properties properties = new Properties() + properties.load(project.rootProject.file('local.properties').newDataInputStream()) + if (properties.getProperty("KEY_LOCATE") != null) { + locale = properties.getProperty("KEY_LOCATE") + } + if (properties.getProperty("KEYSTORE_PASSWORD") != null) { + keystorePwd = properties.getProperty("KEYSTORE_PASSWORD") + } + if (properties.getProperty("KEYSTORE_ALIAS") != null) { + alias = properties.getProperty("KEYSTORE_ALIAS") + } + if (properties.getProperty("KEY_PASSWORD") != null) { + pwd = properties.getProperty("KEY_PASSWORD") + } + } + + if (locale != null) { + def keyFile = file(locale) + if (keyFile.exists()) { + storeFile keyFile + storePassword keystorePwd + keyAlias alias + keyPassword pwd + } + } + } } buildTypes { @@ -35,15 +89,14 @@ android { signingConfig signingConfigs.debug } release { - signingConfig signingConfigs.debug + debuggable false minifyEnabled false - //shrinkResources true proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' } } testOptions { unitTests.all { - jvmArgs '-noverify' + jvmArgs '-noverify', '-Dnet.bytebuddy.experimental=true' } } @@ -51,13 +104,20 @@ android { productFlavors { normal { dimension "version" + targetSdkVersion 34 + versionCode 1003003001 + signingConfig signingConfigs.nihility } vc105 { dimension "version" versionCode = 105 + targetSdkVersion rootProject.ext.targetSdkVersion + signingConfig signingConfigs.nihility } prebuilt { dimension "version" + targetSdkVersion rootProject.ext.targetSdkVersion + signingConfig signingConfigs.nihility } } androidComponents { @@ -68,10 +128,6 @@ android { } } - aspectjx { - enabled = false - } - greendao { // Must upgrade version code! schemaVersion 17 @@ -79,51 +135,33 @@ android { } namespace 'top.trumeet.mipush.provider' - signingConfigs { - debug { - v1SigningEnabled true - v2SigningEnabled true - enableV3Signing = true - enableV4Signing = true - - def locale = project.rootProject.file(".yuuta.jks") - def keystorePwd = System.getenv("KEYSTORE_PASS") - def alias = System.getenv("ALIAS_NAME") - def pwd = System.getenv("ALIAS_PASS") - if (project.rootProject.file('local.properties').exists()) { - Properties properties = new Properties() - properties.load(project.rootProject.file('local.properties').newDataInputStream()) - locale = properties.getProperty("KEY_LOCATE") - keystorePwd = properties.getProperty("KEYSTORE_PASSWORD") - alias = properties.getProperty("KEYSTORE_ALIAS") - pwd = properties.getProperty("KEY_PASSWORD") - } - - if (locale != null) { - storeFile file(locale) - storePassword keystorePwd - keyAlias alias - keyPassword pwd - } - } - } compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = '11' } lintOptions { checkReleaseBuilds false - // Or, if you prefer, you can continue to check for errors in release builds, - // but continue the build even when errors are found: abortOnError false } buildFeatures { compose true + buildConfig true + } + packagingOptions { + jniLibs { + // AGP 8.2 cannot page-align uncompressed JNI payloads for 16 KiB devices. + // Compressing them lets PackageManager extract them with the platform's + // native-library alignment and removes Android 15/16's compatibility dialog. + useLegacyPackaging true + } } composeOptions { - kotlinCompilerExtensionVersion "1.4.0" + // Kotlin 2.x uses the Compose compiler Gradle plugin. } namespace 'com.xiaomi.xmsf' } @@ -136,12 +174,27 @@ android.applicationVariants.all { variant -> variant.getProcessJavaResourcesProvider().get().dependsOn(taskName) } +gradle.taskGraph.whenReady { taskGraph -> + boolean requiresProjectKey = taskGraph.allTasks.any { task -> + String name = task.name.toLowerCase() + (name.contains("normalrelease") || name.contains("vc105release")) && + (name.startsWith("assemble") || name.startsWith("package") || name.startsWith("sign")) + } + if (requiresProjectKey) { + def projectStore = android.signingConfigs.nihility.storeFile + if (projectStore == null || !projectStore.exists()) { + throw new GradleException("Project signing key (MIPUSH_KEYSTORE_PATH / KEY_LOCATE) is missing. Release build cannot proceed.") + } + } +} + dependencies { // modules { implementation project(':common') implementation project(':condom') implementation project(':mipush_hook') compileOnly files(project(':mipush_hook').ext.mipushLib) + implementation 'org.aspectj:aspectjrt:1.9.22.1' // } // feature dependencies { @@ -165,6 +218,10 @@ dependencies { // Test { testImplementation 'junit:junit:4.13.2' + // Android's mockable android.jar exposes org.json methods as throwing stubs in + // local JVM tests. Use the platform-compatible JSON implementation for parser + // tests; this remains test-only and is never packaged into the APK. + testImplementation 'org.json:json:20180813' androidTestImplementation 'androidx.test.ext:junit:1.2.1' androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' androidTestImplementation("org.mockito:mockito-android:4.11.0") @@ -179,10 +236,15 @@ dependencies { implementation 'androidx.palette:palette:1.0.0' - implementation "androidx.compose.ui:ui:1.4.0" - implementation "androidx.compose.material3:material3:1.0.0" - implementation "androidx.compose.ui:ui-tooling-preview:1.4.0" - implementation "androidx.compose.ui:ui-tooling:1.4.0" + implementation "androidx.compose.ui:ui:1.7.0" + // Keep Material 3 available as the compatibility layer for capabilities that Miuix 0.2.9 + // does not expose, while page structure and primary controls use the Miuix components. + implementation "androidx.compose.foundation:foundation:1.7.1" + implementation "androidx.compose.material3:material3:1.3.0" + implementation "androidx.compose.material:material-icons-core:1.7.1" + implementation "top.yukonga.miuix.kmp:miuix-android:0.2.9" + implementation "androidx.compose.ui:ui-tooling-preview:1.7.0" + implementation "androidx.compose.ui:ui-tooling:1.7.0" implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.6.1" implementation "androidx.activity:activity-compose:1.7.0" implementation 'androidx.navigation:navigation-compose:2.6.0' @@ -194,4 +256,4 @@ dependencies { // } -} \ No newline at end of file +} diff --git a/push/src/androidTest/java/test/com/nihility/InternalMessengerTest.java b/push/src/androidTest/java/test/com/nihility/InternalMessengerTest.java index dd117ba46..6f756a38f 100644 --- a/push/src/androidTest/java/test/com/nihility/InternalMessengerTest.java +++ b/push/src/androidTest/java/test/com/nihility/InternalMessengerTest.java @@ -1,5 +1,6 @@ package test.com.nihility; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; import android.content.Context; @@ -7,14 +8,17 @@ import android.content.IntentFilter; import androidx.test.core.app.ApplicationProvider; +import androidx.test.platform.app.InstrumentationRegistry; import com.nihility.InternalMessenger; import org.junit.Before; +import org.junit.After; import org.junit.Test; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; public class InternalMessengerTest { private final static String INTENT_ACTION = "test"; @@ -28,6 +32,12 @@ public void setUp() { receiver.register(intentFilter); } + @After + public void tearDown() { + receiver.close(); + sender.close(); + } + @Test public void receiveFromAnotherMessenger() throws InterruptedException { CountDownLatch doneSignal = new CountDownLatch(1); @@ -55,14 +65,89 @@ public void allListenersAreCalled() throws InterruptedException { public void receiveByMultipleMessengers() throws InterruptedException { int messengerCount = 10; CountDownLatch doneSignal = new CountDownLatch(messengerCount); + InternalMessenger[] messengers = new InternalMessenger[messengerCount]; for (int i = 0; i < messengerCount; i++) { InternalMessenger messenger = new InternalMessenger(applicationContext); + messengers[i] = messenger; messenger.register(intentFilter); messenger.addListener(intent -> doneSignal.countDown()); } sender.send(new Intent(INTENT_ACTION)); + assertTrue(doneSignal.await(1, TimeUnit.SECONDS)); + for (InternalMessenger messenger : messengers) { + messenger.close(); + } + } + + @Test + public void repeatedRegisterDoesNotDeliverDuplicates() throws InterruptedException { + receiver.register(intentFilter); + CountDownLatch doneSignal = new CountDownLatch(1); + AtomicInteger deliveries = new AtomicInteger(); + receiver.addListener(intent -> { + deliveries.incrementAndGet(); + doneSignal.countDown(); + }); + + sender.send(new Intent(INTENT_ACTION)); + + assertTrue(doneSignal.await(1, TimeUnit.SECONDS)); + InstrumentationRegistry.getInstrumentation().waitForIdleSync(); + assertEquals(1, deliveries.get()); + } + + @Test + public void distinctRegistrationsRemainActive() throws InterruptedException { + String secondAction = "test.second"; + receiver.register(new IntentFilter(secondAction)); + CountDownLatch doneSignal = new CountDownLatch(2); + receiver.addListener(intent -> doneSignal.countDown()); + + sender.send(new Intent(INTENT_ACTION)); + sender.send(new Intent(secondAction)); + + assertTrue(doneSignal.await(1, TimeUnit.SECONDS)); + } + + @Test + public void closeIsIdempotentAndStopsDelivery() throws InterruptedException { + AtomicInteger deliveries = new AtomicInteger(); + receiver.addListener(intent -> deliveries.incrementAndGet()); + + receiver.close(); + receiver.close(); + sender.send(new Intent(INTENT_ACTION)); + InstrumentationRegistry.getInstrumentation().waitForIdleSync(); + + assertEquals(0, deliveries.get()); + } + + @Test + public void closedMessengerCannotBeRegisteredAgain() throws InterruptedException { + AtomicInteger deliveries = new AtomicInteger(); + receiver.addListener(intent -> deliveries.incrementAndGet()); + receiver.close(); + receiver.register(intentFilter); + receiver.addListener(intent -> deliveries.incrementAndGet()); + + sender.send(new Intent(INTENT_ACTION)); + InstrumentationRegistry.getInstrumentation().waitForIdleSync(); + + assertEquals(0, deliveries.get()); + } + + @Test + public void listenerCanMutateListenersDuringDelivery() throws InterruptedException { + CountDownLatch doneSignal = new CountDownLatch(1); + receiver.addListener(intent -> { + receiver.addListener(ignored -> { }); + doneSignal.countDown(); + }); + + sender.send(new Intent(INTENT_ACTION)); + assertTrue(doneSignal.await(1, TimeUnit.SECONDS)); } -} \ No newline at end of file +} diff --git a/push/src/androidTest/java/test/com/nihility/service/service/test/com/xiaomi/push/service/MIPushEventProcessorAspectTest.java b/push/src/androidTest/java/test/com/nihility/service/service/test/com/xiaomi/push/service/MIPushEventProcessorAspectTest.java index f8e1fe178..981d93504 100644 --- a/push/src/androidTest/java/test/com/nihility/service/service/test/com/xiaomi/push/service/MIPushEventProcessorAspectTest.java +++ b/push/src/androidTest/java/test/com/nihility/service/service/test/com/xiaomi/push/service/MIPushEventProcessorAspectTest.java @@ -80,10 +80,10 @@ public void awakeIfIsSystemApp() throws InvocationTargetException, NoSuchMethodE } @Test - public void awakeIfIsXiaomiApp() throws InvocationTargetException, NoSuchMethodException, IllegalAccessException { - assertTrue(shouldAwake(null, "com.mi.xxx")); - assertTrue(shouldAwake(null, "com.miui.xxx")); - assertTrue(shouldAwake(null, "com.xiaomi.xxx")); + public void packagePrefixesDoNotBypassAppAlivePolicy() throws InvocationTargetException, NoSuchMethodException, IllegalAccessException { + assertFalse(shouldAwake(null, "com.mi.mipushframework.missing")); + assertFalse(shouldAwake(null, "com.miui.mipushframework.missing")); + assertFalse(shouldAwake(null, "com.xiaomi.mipushframework.missing")); } @Test @@ -111,6 +111,18 @@ public void awakeIfAwakeFieldIsTrueByConfigure() throws InvocationTargetExceptio assertTrue(shouldAwake(metaInfo, packageName)); } + @Test + public void configurationUsesRealTargetInsteadOfWrapperPackage() throws InvocationTargetException, NoSuchMethodException, IllegalAccessException, JSONException { + String targetPackage = "com.example.target.missing"; + String wrapperPackage = "com.example.wrapper.missing"; + PushMetaInfo metaInfo = new PushMetaInfo(); + metaInfo.extra = new HashMap<>(); + + setAllowAwakeByConfigurationFor(targetPackage); + + assertTrue(shouldAwake(metaInfo, targetPackage, wrapperPackage)); + } + @Test public void awakeIfIsRegistrationMessage() throws InvocationTargetException, NoSuchMethodException, IllegalAccessException, JSONException { container.action = ActionType.Registration; @@ -157,7 +169,11 @@ private boolean shouldAwake(PushMetaInfo metaInfo) throws NoSuchMethodException, } private boolean shouldAwake(PushMetaInfo metaInfo, String packageName) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException { - container.setPackageName(packageName); + return shouldAwake(metaInfo, packageName, packageName); + } + + private boolean shouldAwake(PushMetaInfo metaInfo, String packageName, String containerPackageName) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException { + container.setPackageName(containerPackageName); container.metaInfo = metaInfo; return JavaCalls.callStaticMethodOrThrow(MIPushEventProcessor.class, "shouldSendBroadcast", service, packageName, container, metaInfo); diff --git a/push/src/main/AndroidManifest.xml b/push/src/main/AndroidManifest.xml index 3e2cc4eb7..fd518a38b 100644 --- a/push/src/main/AndroidManifest.xml +++ b/push/src/main/AndroidManifest.xml @@ -3,11 +3,25 @@ xmlns:tools="http://schemas.android.com/tools"> + + + + + + + - + @@ -54,6 +68,20 @@ android:name=".RemoveDozeActivity" android:exported="true" /> + + + @@ -65,7 +93,8 @@ --> + android:icon="@mipmap/ic_launcher" + android:exported="true"> @@ -89,7 +118,7 @@ - + - + @@ -119,7 +148,7 @@ + android:exported="false"> @@ -141,7 +170,7 @@ - + @@ -149,29 +178,53 @@ - + - + + + + + + + + + + + + android:exported="false" /> + android:exported="true" + android:foregroundServiceType="specialUse"> + + @@ -198,6 +251,7 @@ diff --git a/push/src/main/java/com/nihility/InternalMessenger.java b/push/src/main/java/com/nihility/InternalMessenger.java index a33c61147..8a6aebb08 100644 --- a/push/src/main/java/com/nihility/InternalMessenger.java +++ b/push/src/main/java/com/nihility/InternalMessenger.java @@ -4,34 +4,126 @@ import android.content.Context; import android.content.Intent; import android.content.IntentFilter; +import android.os.PatternMatcher; import androidx.localbroadcastmanager.content.LocalBroadcastManager; import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArrayList; -public class InternalMessenger extends BroadcastReceiver { +public class InternalMessenger extends BroadcastReceiver implements AutoCloseable { private final LocalBroadcastManager localBroadcast; - private final ArrayList listeners = new ArrayList<>(); + private final CopyOnWriteArrayList listeners = new CopyOnWriteArrayList<>(); + private final Set registeredFilters = new HashSet<>(); + private boolean registered; + private volatile boolean closed; public InternalMessenger(Context context) { - localBroadcast = LocalBroadcastManager.getInstance(context); + Context applicationContext = context.getApplicationContext(); + localBroadcast = LocalBroadcastManager.getInstance( + applicationContext == null ? context : applicationContext); } public void send(Intent intent) { - localBroadcast.sendBroadcast(intent); + if (!closed) { + localBroadcast.sendBroadcast(intent); + } + } + + public synchronized void register(IntentFilter intentFilter) { + if (closed) { + return; + } + String signature = filterSignature(intentFilter); + if (!registeredFilters.add(signature)) { + return; + } + try { + localBroadcast.registerReceiver(this, intentFilter); + registered = true; + } catch (RuntimeException | Error e) { + registeredFilters.remove(signature); + throw e; + } + } + + private static String filterSignature(IntentFilter filter) { + ArrayList fields = new ArrayList<>(); + for (int i = 0; i < filter.countActions(); i++) { + fields.add(field("action", filter.getAction(i))); + } + for (int i = 0; i < filter.countCategories(); i++) { + fields.add(field("category", filter.getCategory(i))); + } + for (int i = 0; i < filter.countDataTypes(); i++) { + fields.add(field("type", filter.getDataType(i))); + } + for (int i = 0; i < filter.countDataSchemes(); i++) { + fields.add(field("scheme", filter.getDataScheme(i))); + } + for (int i = 0; i < filter.countDataSchemeSpecificParts(); i++) { + fields.add(field("schemePart", pattern(filter.getDataSchemeSpecificPart(i)))); + } + for (int i = 0; i < filter.countDataAuthorities(); i++) { + IntentFilter.AuthorityEntry authority = filter.getDataAuthority(i); + fields.add(field("authority", authority.getHost() + "\u0000" + authority.getPort())); + } + for (int i = 0; i < filter.countDataPaths(); i++) { + fields.add(field("path", pattern(filter.getDataPath(i)))); + } + Collections.sort(fields); + return fields.toString(); + } + + private static String pattern(PatternMatcher matcher) { + return matcher.getType() + ":" + matcher.getPath(); + } + + private static String field(String name, Object value) { + String text = String.valueOf(value); + return name.length() + ":" + name + text.length() + ":" + text; } - public void register(IntentFilter intentFilter) { - localBroadcast.registerReceiver(this, intentFilter); + public synchronized void addListener(MessageListener listener) { + if (!closed) { + listeners.add(listener); + } } - public void addListener(MessageListener listener) { - listeners.add(listener); + /** + * Releases this receiver from LocalBroadcastManager. Long-lived owners such as services + * must call this from their lifecycle teardown; LocalBroadcastManager otherwise retains + * the receiver (and its service/context graph) for the lifetime of the process. + */ + @Override + public synchronized void close() { + if (closed) { + return; + } + closed = true; + try { + if (registered) { + localBroadcast.unregisterReceiver(this); + } + } finally { + registered = false; + registeredFilters.clear(); + listeners.clear(); + } } @Override public void onReceive(Context context, Intent intent) { + if (closed) { + return; + } for (MessageListener listener : listeners) { + if (closed) { + return; + } listener.onReceive(intent); } } diff --git a/push/src/main/java/com/nihility/service/ForegroundHelper.java b/push/src/main/java/com/nihility/service/ForegroundHelper.java index 950a48ca6..aa002c39b 100644 --- a/push/src/main/java/com/nihility/service/ForegroundHelper.java +++ b/push/src/main/java/com/nihility/service/ForegroundHelper.java @@ -25,46 +25,62 @@ public ForegroundHelper(Service service) { public void startForeground() { createNotificationGroupForPushStatus(); - if (Global.ConfigCenter().isStartForegroundService()) { - showForegroundNotificationToKeepAlive(); - } else { + // A service reached through startForegroundService() must call + // startForeground() even when the user does not want a persistent status + // notification. Promote first to satisfy Android's five-second contract, + // then leave foreground state immediately for the non-persistent mode. + showForegroundNotificationToKeepAlive(); + if (!Global.ConfigCenter().isStartForegroundService()) { stopForegroundNotification(); } } public void stopForegroundNotification() { - ServiceCompat.stopForeground(service, ServiceCompat.STOP_FOREGROUND_REMOVE); + try { + ServiceCompat.stopForeground(service, ServiceCompat.STOP_FOREGROUND_REMOVE); + } catch (Throwable ignored) { + } } void showForegroundNotificationToKeepAlive() { - //if (ConfigCenter.getInstance().foregroundNotification || Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - { - Notification notification = new NotificationCompat.Builder(service, - CHANNEL_STATUS) - .setContentTitle(service.getString(R.string.notification_alive)) - .setSmallIcon(R.drawable.ic_notifications_black_24dp) - .setPriority(NotificationCompat.PRIORITY_MIN) - .setOngoing(true) - .setShowWhen(true) - .build(); + Notification notification = new NotificationCompat.Builder(service, + CHANNEL_STATUS) + .setContentTitle(service.getString(R.string.notification_alive)) + .setSmallIcon(R.drawable.ic_notifications_black_24dp) + .setPriority(NotificationCompat.PRIORITY_MIN) + .setOngoing(true) + .setShowWhen(true) + .build(); - service.startForeground(NOTIFICATION_ALIVE_ID, notification); + try { + int foregroundServiceType = 0; + if (Build.VERSION.SDK_INT >= 34) { + // ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE = 0x40000000 (1 << 30) + foregroundServiceType = 0x40000000; + } + ServiceCompat.startForeground(service, NOTIFICATION_ALIVE_ID, notification, foregroundServiceType); + } catch (Throwable e) { + // Catches android.app.ForegroundServiceStartNotAllowedException on API 31+ + // and SecurityException / IllegalStateException } } void createNotificationGroupForPushStatus() { - NotificationManagerCompat manager = NotificationManagerCompat.from(service.getApplicationContext()); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - String groupId = "status_group"; - NotificationChannelGroupCompat.Builder group = - new NotificationChannelGroupCompat.Builder(groupId) - .setName(CHANNEL_STATUS); - manager.createNotificationChannelGroup(group.build()); + try { + NotificationManagerCompat manager = NotificationManagerCompat.from(service.getApplicationContext()); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + String groupId = "status_group"; + NotificationChannelGroupCompat.Builder group = + new NotificationChannelGroupCompat.Builder(groupId) + .setName(CHANNEL_STATUS); + manager.createNotificationChannelGroup(group.build()); - NotificationChannelCompat.Builder channel = new NotificationChannelCompat.Builder( - CHANNEL_STATUS, NotificationManager.IMPORTANCE_MIN) - .setName(service.getString(R.string.notification_category_alive)).setGroup(groupId); - manager.createNotificationChannel(channel.build()); + NotificationChannelCompat.Builder channel = new NotificationChannelCompat.Builder( + CHANNEL_STATUS, NotificationManager.IMPORTANCE_MIN) + .setName(service.getString(R.string.notification_category_alive)).setGroup(groupId); + manager.createNotificationChannel(channel.build()); + } + } catch (Throwable ignored) { } } -} \ No newline at end of file +} diff --git a/push/src/main/java/com/nihility/service/MessengerAbility.java b/push/src/main/java/com/nihility/service/MessengerAbility.java index 6b9005ca6..ccae2686c 100644 --- a/push/src/main/java/com/nihility/service/MessengerAbility.java +++ b/push/src/main/java/com/nihility/service/MessengerAbility.java @@ -13,4 +13,9 @@ public MessengerAbility(XMPushServiceMessenger messenger) { public void connectionStatusChanged(ConnectionStatus connectionStatus) { messenger.notifyConnectionStatusChanged(connectionStatus.ordinal()); } + + @Override + public void destroy() { + messenger.close(); + } } diff --git a/push/src/main/java/com/nihility/service/RegistrationRecorder.java b/push/src/main/java/com/nihility/service/RegistrationRecorder.java index 8ff87b00e..464437f31 100644 --- a/push/src/main/java/com/nihility/service/RegistrationRecorder.java +++ b/push/src/main/java/com/nihility/service/RegistrationRecorder.java @@ -18,7 +18,12 @@ public class RegistrationRecorder { Context context; public void initContext(Context context) { - this.context = context; + Context applicationContext = context == null ? null : context.getApplicationContext(); + this.context = applicationContext == null ? context : applicationContext; + } + + public void clearContext() { + context = null; } public void recordRegSec(XmPushActionContainer container) { @@ -40,4 +45,4 @@ public static String getRegSec(Context pushService, XmPushActionContainer contai } return null; } -} \ No newline at end of file +} diff --git a/push/src/main/java/com/nihility/service/XMPushServiceAbility.java b/push/src/main/java/com/nihility/service/XMPushServiceAbility.java index 940043d78..ebcad5461 100644 --- a/push/src/main/java/com/nihility/service/XMPushServiceAbility.java +++ b/push/src/main/java/com/nihility/service/XMPushServiceAbility.java @@ -16,17 +16,58 @@ import com.xiaomi.push.service.XMPushService; import com.xiaomi.push.service.XMPushServiceMessenger; import com.xiaomi.xmsf.push.control.XMOutbound; +import com.xiaomi.xmsf.push.control.PushControllerUtils; public class XMPushServiceAbility extends XMPushServiceListenerNotifier { - public static XMPushService xmPushService; + /** + * Compatibility handle used by the event replay tool. It is populated only while the + * service is alive and cleared deterministically from destroy(), so it cannot retain a + * stopped Service for the rest of the process lifetime. + */ + public static volatile XMPushService xmPushService; + private XMPushService pushService; public XMPushServiceAbility(XMPushService pushService) { - xmPushService = pushService; + this.pushService = pushService; Global.RegistrationRecorder().initContext(pushService); condomContext(pushService); initListeners(pushService); } + @Override + public void created() { + XMPushService service = pushService; + try { + super.created(); + xmPushService = service; + PushControllerUtils.onPushServiceCreated(); + } catch (RuntimeException | Error e) { + try { + super.destroy(); + } catch (RuntimeException | Error cleanupError) { + e.addSuppressed(cleanupError); + } + Global.RegistrationRecorder().clearContext(); + pushService = null; + throw e; + } + } + + @Override + public void destroy() { + XMPushService service = pushService; + try { + super.destroy(); + } finally { + if (xmPushService == service) { + xmPushService = null; + } + Global.RegistrationRecorder().clearContext(); + pushService = null; + PushControllerUtils.onPushServiceDestroyed(); + } + } + private void initListeners(XMPushService pushService) { addListener(new RegisterRecordAbility(new RegisterRecorder(pushService))); diff --git a/push/src/main/java/com/nihility/service/XMPushServiceListenerNotifier.java b/push/src/main/java/com/nihility/service/XMPushServiceListenerNotifier.java index f56b4c9e4..e2b7376b8 100644 --- a/push/src/main/java/com/nihility/service/XMPushServiceListenerNotifier.java +++ b/push/src/main/java/com/nihility/service/XMPushServiceListenerNotifier.java @@ -2,12 +2,13 @@ import android.content.Intent; -import java.util.ArrayList; +import java.util.concurrent.CopyOnWriteArrayList; public class XMPushServiceListenerNotifier implements XMPushServiceListener { - private final ArrayList listeners = new ArrayList<>(); + private final CopyOnWriteArrayList listeners = + new CopyOnWriteArrayList<>(); - public void addListener(XMPushServiceListener listener) { + public final void addListener(XMPushServiceListener listener) { listeners.add(listener); } @@ -20,8 +21,32 @@ public void created() { @Override public void destroy() { - for (XMPushServiceListener listener : listeners) { - listener.destroy(); + RuntimeException firstRuntimeFailure = null; + Error firstError = null; + try { + for (XMPushServiceListener listener : listeners) { + try { + listener.destroy(); + } catch (RuntimeException e) { + if (firstRuntimeFailure == null) { + firstRuntimeFailure = e; + } + } catch (Error e) { + if (firstError == null) { + firstError = e; + } + } + } + } finally { + // Aspect instances can outlive a stopped Service. Drop listeners so they cannot + // retain the Service and its receiver/notification helpers until process death. + listeners.clear(); + } + if (firstError != null) { + throw firstError; + } + if (firstRuntimeFailure != null) { + throw firstRuntimeFailure; } } diff --git a/push/src/main/java/com/nihility/utils/Hooker.java b/push/src/main/java/com/nihility/utils/Hooker.java index 2b3b986a9..3c636b477 100644 --- a/push/src/main/java/com/nihility/utils/Hooker.java +++ b/push/src/main/java/com/nihility/utils/Hooker.java @@ -66,7 +66,6 @@ public HookedMethodHandler hookedMethodHandler() { private static void hookMiPushSDK(final Context context) { try { - hookField(SmackConfiguration.class, "pingInterval", 3 * 60 * 1000); hookMiPushServerHost(); AppRegionStorage regionStorage = AppRegionStorage.getInstance(context.getApplicationContext()); regionStorage.setRegion(Region.China.name()); diff --git a/push/src/main/java/com/nihility/utils/MockMIPushMessage.java b/push/src/main/java/com/nihility/utils/MockMIPushMessage.java index 3bf97b3d7..f964cd747 100644 --- a/push/src/main/java/com/nihility/utils/MockMIPushMessage.java +++ b/push/src/main/java/com/nihility/utils/MockMIPushMessage.java @@ -1,6 +1,7 @@ package com.nihility.utils; import android.widget.Toast; +import android.os.SystemClock; import com.elvishew.xlog.Logger; import com.elvishew.xlog.XLog; @@ -10,25 +11,71 @@ import com.xiaomi.push.service.MiPushMessageDuplicateAspect; import com.xiaomi.push.service.XMPushService; import com.xiaomi.xmpush.thrift.XmPushActionContainer; +import com.xiaomi.xmpush.thrift.PushMetaInfo; +import com.xiaomi.xmsf.push.notification.FocusNotificationReplay; import java.lang.reflect.InvocationTargetException; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; import top.trumeet.common.utils.Utils; public class MockMIPushMessage { private static final String TAG = MockMIPushMessage.class.getSimpleName(); private static final Logger logger = XLog.tag(TAG).build(); + private static final AtomicInteger REPLAY_NOTIFY_ID = new AtomicInteger( + 0x60000000 | ((int) SystemClock.uptimeMillis() & 0x0fffffff)); - public static void mockProcessMIPushMessage(XMPushService pushService, XmPushActionContainer container) { + /** + * Dispatch an event as a fresh notification. Historical focus payloads + * contain an expired sequence, so replay must refresh the public timestamp + * fields before entering the normal push pipeline. + * + * @return true when the processor was invoked successfully + */ + public static boolean mockProcessMIPushMessage(XMPushService pushService, + XmPushActionContainer container) { try { - MiPushMessageDuplicateAspect.markAsMock(container); - invokeProcessMiPushMessage(pushService, container); + XmPushActionContainer replayContainer = prepareForReplay(container); + MiPushMessageDuplicateAspect.markAsMock(replayContainer); + invokeProcessMiPushMessage(pushService, replayContainer); + return true; } catch (Exception e) { logger.e("mock notification failure: ", e); - Utils.makeText(pushService, "failure", Toast.LENGTH_SHORT); + if (pushService != null) { + Utils.makeText(pushService, "failure", Toast.LENGTH_SHORT); + } + return false; } } + static XmPushActionContainer prepareForReplay(XmPushActionContainer container) { + XmPushActionContainer replay = NotificationReplayMarker.markedCopy(container); + PushMetaInfo metaInfo = replay.getMetaInfo(); + if (metaInfo == null) { + return replay; + } + long now = System.currentTimeMillis(); + metaInfo.setMessageTs(now); + // A replay should create a visible notification instead of silently + // updating the historical row with the same ID. + metaInfo.setNotifyId(nextReplayNotifyId()); + Map extras = metaInfo.getExtra(); + if (extras != null && !extras.isEmpty()) { + metaInfo.setExtra(FocusNotificationReplay.refreshExtras(extras, now)); + } + return replay; + } + + private static int nextReplayNotifyId() { + int next = REPLAY_NOTIFY_ID.incrementAndGet(); + if (next < 0) { + REPLAY_NOTIFY_ID.compareAndSet(next, 0x60000000); + return 0x60000000; + } + return next; + } + public static void invokeProcessMiPushMessage(XMPushService pushService, XmPushActionContainer container) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, ClassNotFoundException { byte[] mockDecryptedContent = XMPushUtils.packToBytes(container); invokeProcessMiPushMessage(pushService, mockDecryptedContent); diff --git a/push/src/main/java/com/nihility/utils/NotificationReplayMarker.java b/push/src/main/java/com/nihility/utils/NotificationReplayMarker.java new file mode 100644 index 000000000..a597dcd60 --- /dev/null +++ b/push/src/main/java/com/nihility/utils/NotificationReplayMarker.java @@ -0,0 +1,61 @@ +package com.nihility.utils; + +import androidx.annotation.Nullable; + +import com.xiaomi.xmpush.thrift.PushMetaInfo; +import com.xiaomi.xmpush.thrift.XmPushActionContainer; + +import java.util.HashMap; +import java.util.Map; + +/** XMSF-private marker and payload sanitiser for manually replayed notifications. */ +public final class NotificationReplayMarker { + public static final String META_EXTRA_KEY = "__xmsf_internal_manual_replay"; + private static final String MARKED_VALUE = "1"; + + private NotificationReplayMarker() { + } + + /** Creates the replay-owned copy before adding any internal metadata. */ + public static XmPushActionContainer markedCopy(XmPushActionContainer container) { + XmPushActionContainer replay = container.deepCopy(); + mark(replay); + return replay; + } + + public static void mark(XmPushActionContainer container) { + PushMetaInfo metaInfo = container == null ? null : container.getMetaInfo(); + if (metaInfo == null) { + return; + } + Map extras = metaInfo.getExtra() == null + ? new HashMap<>() : new HashMap<>(metaInfo.getExtra()); + extras.put(META_EXTRA_KEY, MARKED_VALUE); + metaInfo.setExtra(extras); + } + + public static boolean isMarked(@Nullable XmPushActionContainer container) { + PushMetaInfo metaInfo = container == null ? null : container.getMetaInfo(); + Map extras = metaInfo == null ? null : metaInfo.getExtra(); + return extras != null && MARKED_VALUE.equals(extras.get(META_EXTRA_KEY)); + } + + /** Returns a deep copy whose application-visible metadata contains no XMSF marker. */ + @Nullable + public static XmPushActionContainer copyWithoutMarker( + @Nullable XmPushActionContainer container) { + if (container == null) { + return null; + } + XmPushActionContainer copy = container.deepCopy(); + PushMetaInfo metaInfo = copy.getMetaInfo(); + if (metaInfo == null || metaInfo.getExtra() == null + || !metaInfo.getExtra().containsKey(META_EXTRA_KEY)) { + return copy; + } + Map extras = new HashMap<>(metaInfo.getExtra()); + extras.remove(META_EXTRA_KEY); + metaInfo.setExtra(extras); + return copy; + } +} diff --git a/push/src/main/java/com/xiaomi/push/revival/NotificationsRevivalForSelfUpdated.kt b/push/src/main/java/com/xiaomi/push/revival/NotificationsRevivalForSelfUpdated.kt index 6dbb85736..825c6dd8f 100644 --- a/push/src/main/java/com/xiaomi/push/revival/NotificationsRevivalForSelfUpdated.kt +++ b/push/src/main/java/com/xiaomi/push/revival/NotificationsRevivalForSelfUpdated.kt @@ -9,6 +9,7 @@ import android.app.NotificationManager import android.app.PendingIntent import android.app.PendingIntent.FLAG_NO_CREATE import android.app.PendingIntent.FLAG_UPDATE_CURRENT +import android.app.PendingIntent.FLAG_IMMUTABLE import android.content.BroadcastReceiver import android.content.Context import android.content.ContextWrapper @@ -85,7 +86,8 @@ private const val TIMEOUT_DEBUG = 5 * 60_000 ) { payload.putExtra(null, sbn) val pi = - PendingIntent.getBroadcast(context, identity, payload, FLAG_UPDATE_CURRENT) + PendingIntent.getBroadcast(context, identity, payload, + FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE) am.set(AlarmManager.ELAPSED_REALTIME, expireAtElapsed, pi) } @@ -151,7 +153,8 @@ private const val TIMEOUT_DEBUG = 5 * 60_000 context: Context?, identity: Int, retriever: Intent - ): PendingIntent? = PendingIntent.getBroadcast(context, identity, retriever, FLAG_NO_CREATE) + ): PendingIntent? = PendingIntent.getBroadcast( + context, identity, retriever, FLAG_NO_CREATE or FLAG_IMMUTABLE) private fun restoreNotification(context: Context, sbn: StatusBarNotification) { var n = sbn.notification @@ -189,4 +192,4 @@ private const val TIMEOUT_DEBUG = 5 * 60_000 } private const val EXTRA_INDEX = "i" -private const val TAG = "MPF.NR" \ No newline at end of file +private const val TAG = "MPF.NR" diff --git a/push/src/main/java/com/xiaomi/push/sdk/MyPushMessageHandler.java b/push/src/main/java/com/xiaomi/push/sdk/MyPushMessageHandler.java index cbc188a87..15e3d2c03 100644 --- a/push/src/main/java/com/xiaomi/push/sdk/MyPushMessageHandler.java +++ b/push/src/main/java/com/xiaomi/push/sdk/MyPushMessageHandler.java @@ -95,8 +95,9 @@ public static void cancelNotification(Context context, Bundle bundle) { public static void cancelNotification(Context context, Bundle bundle, XmPushActionContainer container) { int notificationId = bundle.getInt(Constants.INTENT_NOTIFICATION_ID, 0); String notificationGroup = bundle.getString(Constants.INTENT_NOTIFICATION_GROUP); + String targetPackage = MyMIPushNotificationHelper.getNotificationTargetPackage(container); try { - Configurations.getInstance().handle(container.packageName, container); + Configurations.getInstance().handle(targetPackage, container); } catch (Exception e) { logger.e("cancelNotification", e); } @@ -116,7 +117,7 @@ public static void launchApp(Context context, XmPushActionContainer container) { return; } - String targetPackage = container.getPackageName(); + String targetPackage = MyMIPushNotificationHelper.getNotificationTargetPackage(container); activeApp(context, targetPackage); pullUpApp(context, targetPackage, container); @@ -131,7 +132,7 @@ public static ComponentName startService(Context context, XmPushActionContainer public static ComponentName forwardToTargetApplication(Context context, byte[] payload) { XmPushActionContainer container = XMPushUtils.packToContainer(payload); PushMetaInfo metaInfo = container.getMetaInfo(); - String targetPackage = container.getPackageName(); + String targetPackage = MyMIPushNotificationHelper.getNotificationTargetPackage(container); final Intent localIntent = new Intent(PushConstants.MIPUSH_ACTION_NEW_MESSAGE); localIntent.setComponent(new ComponentName(targetPackage, "com.xiaomi.mipush.sdk.PushMessageHandler")); @@ -168,7 +169,8 @@ private static void activeApp(Context context, String targetPackage) { private static Intent getJumpIntent(Context context, XmPushActionContainer container) { Intent intent = MyMIPushNotificationHelper.getSdkIntent(context, container); if (intent == null) { - intent = getJumpIntentFromPkg(context, container.packageName); + intent = getJumpIntentFromPkg( + context, MyMIPushNotificationHelper.getNotificationTargetPackage(container)); } return intent; } @@ -225,7 +227,11 @@ private static long pullUpApp(Context context, String targetPackage, XmPushActio if (intent == null) { throw new RuntimeException("can not get default activity for " + targetPackage); } else { - intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP); + // This method is invoked from an IntentService. Explicitly + // mark the fallback launcher as a new task so the legacy + // opt-out path remains valid on Android 16/HyperOS. + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK + | Intent.FLAG_ACTIVITY_SINGLE_TOP); context.startActivity(intent); logger.d(packageInfo(targetPackage, "start activity")); @@ -243,8 +249,11 @@ private static long pullUpApp(Context context, String targetPackage, XmPushActio if (i == (APP_CHECK_FRONT_MAX_RETRY / 2)) { intent = getJumpIntentFromPkg(context, targetPackage); - intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP); - context.startActivity(intent); + if (intent != null) { + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK + | Intent.FLAG_ACTIVITY_SINGLE_TOP); + context.startActivity(intent); + } } } diff --git a/push/src/main/java/com/xiaomi/push/sdk/NotificationClickHandoffPolicy.java b/push/src/main/java/com/xiaomi/push/sdk/NotificationClickHandoffPolicy.java new file mode 100644 index 000000000..4ebb88453 --- /dev/null +++ b/push/src/main/java/com/xiaomi/push/sdk/NotificationClickHandoffPolicy.java @@ -0,0 +1,48 @@ +package com.xiaomi.push.sdk; + +/** + * Pure decision policy for notification click hand-off. + * + *

Private target Activities cannot be started by XMSF, but opening the + * target launcher before handing the payload to its SDK creates a visible + * launcher-to-deep-link task gap. The policy therefore gives the target SDK + * the original user-initiated click first and uses an exported route or the + * launcher only when delivery is rejected or produces no visible UI.

+ */ +public final class NotificationClickHandoffPolicy { + public enum Action { + DISPATCH_SDK_FIRST, + START_DIRECT_TARGET, + WAIT_FOR_TARGET, + START_FALLBACK, + FINISH + } + + private NotificationClickHandoffPolicy() { + } + + public static Action initialAction( + boolean manualReplay, boolean targetActivityPrivate) { + return manualReplay || targetActivityPrivate + ? Action.DISPATCH_SDK_FIRST + : Action.START_DIRECT_TARGET; + } + + public static Action afterSdkDispatch(boolean accepted, boolean targetVisible) { + if (!accepted) { + return Action.START_FALLBACK; + } + return targetVisible ? Action.FINISH : Action.WAIT_FOR_TARGET; + } + + public static Action afterNavigationProbe( + boolean targetVisible, boolean timedOut, boolean canLaunchFallback) { + if (targetVisible) { + return Action.FINISH; + } + if (!timedOut) { + return Action.WAIT_FOR_TARGET; + } + return canLaunchFallback ? Action.START_FALLBACK : Action.FINISH; + } +} diff --git a/push/src/main/java/com/xiaomi/push/sdk/TargetSdkClickDispatcher.java b/push/src/main/java/com/xiaomi/push/sdk/TargetSdkClickDispatcher.java new file mode 100644 index 000000000..8603a0e05 --- /dev/null +++ b/push/src/main/java/com/xiaomi/push/sdk/TargetSdkClickDispatcher.java @@ -0,0 +1,305 @@ +package com.xiaomi.push.sdk; + +import android.app.ActivityOptions; +import android.app.PendingIntent; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.pm.ActivityInfo; +import android.content.pm.ComponentInfo; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.content.pm.ServiceInfo; +import android.os.Build; +import android.os.Bundle; + +import androidx.annotation.Nullable; + +import com.nihility.XMPushUtils; +import com.nihility.utils.NotificationReplayMarker; +import com.xiaomi.push.service.MIPushNotificationHelper; +import com.xiaomi.push.service.MyMIPushNotificationHelper; +import com.xiaomi.push.service.PushConstants; +import com.xiaomi.xmpush.thrift.PushMetaInfo; +import com.xiaomi.xmpush.thrift.XmPushActionContainer; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.Collections; +import java.util.List; + +/** + * Capability-based SDK click delivery for routes owned by the target MiPush SDK. + * + *

A notification click may enter XMSF first when the sender-declared Activity + * is private. Starting the target service directly while the target UID is + * still backgrounded is rejected by modern Android. This dispatcher uses an + * immutable, one-shot PendingIntent directly from the user-click trampoline, + * allowing supported platform/OEM implementations to retain the + * user-initiated hand-off metadata. The target launcher is only a bounded + * fallback when SDK delivery produces no visible UI. Neither mechanism + * requires package-specific routing.

+ */ +public final class TargetSdkClickDispatcher { + private static final String SDK_SERVICE_CLASS = + MyMIPushNotificationHelper.CLASS_NAME_PUSH_MESSAGE_HANDLER; + private static final String RECEIVER_PERMISSION_SUFFIX = + ".permission.MIPUSH_RECEIVE"; + + public enum DispatchResult { + SERVICE_DELIVERY_ACCEPTED, + BROADCAST_DELIVERY_ACCEPTED, + UNAVAILABLE, + FAILED; + + /** Delivery acceptance is deliberately not a claim that navigation completed. */ + public boolean isAccepted() { + return this == SERVICE_DELIVERY_ACCEPTED + || this == BROADCAST_DELIVERY_ACCEPTED; + } + } + + enum Kind { + SERVICE, + RECEIVER + } + + static final class Candidate { + final String packageName; + final String className; + final boolean enabled; + final boolean exported; + + Candidate(String packageName, String className, boolean enabled, boolean exported) { + this.packageName = packageName; + this.className = className; + this.enabled = enabled; + this.exported = exported; + } + } + + static final class Capability { + final Kind kind; + final Candidate candidate; + + Capability(Kind kind, Candidate candidate) { + this.kind = kind; + this.candidate = candidate; + } + } + + interface CapabilitySource { + @Nullable Candidate service(String targetPackage, String serviceClass); + + List receivers(String targetPackage, String action); + } + + private TargetSdkClickDispatcher() { + } + + static String receiverPermission(String targetPackage) { + return targetPackage == null ? null : targetPackage + RECEIVER_PERMISSION_SUFFIX; + } + + public static DispatchResult dispatchReplay( + Context context, @Nullable XmPushActionContainer replayContainer) { + if (context == null || replayContainer == null + || MyMIPushNotificationHelper.getNotificationTargetPackage(replayContainer) + .isEmpty()) { + return DispatchResult.UNAVAILABLE; + } + XmPushActionContainer targetContainer = + NotificationReplayMarker.copyWithoutMarker(replayContainer); + if (targetContainer == null) { + return DispatchResult.FAILED; + } + byte[] targetPayload; + try { + targetPayload = XMPushUtils.packToBytes(targetContainer); + } catch (Throwable ignored) { + return DispatchResult.FAILED; + } + + return dispatchPayload(context, targetContainer, targetPayload); + } + + /** Deliver an unmodified live-notification payload through the target SDK. */ + public static DispatchResult dispatchPayload( + Context context, + @Nullable XmPushActionContainer container, + @Nullable byte[] targetPayload) { + if (context == null || container == null || targetPayload == null + || targetPayload.length == 0 + || MyMIPushNotificationHelper.getNotificationTargetPackage(container).isEmpty()) { + return DispatchResult.UNAVAILABLE; + } + String targetPackage = MyMIPushNotificationHelper + .getNotificationTargetPackage(container); + final Capability capability; + try { + capability = selectCapability( + targetPackage, new AndroidCapabilitySource(context.getPackageManager())); + } catch (Throwable ignored) { + return DispatchResult.FAILED; + } + if (capability == null) { + return DispatchResult.UNAVAILABLE; + } + + Intent targetIntent = new Intent(PushConstants.MIPUSH_ACTION_NEW_MESSAGE) + .setPackage(targetPackage) + .putExtra(PushConstants.MIPUSH_EXTRA_PAYLOAD, targetPayload) + .putExtra(PushConstants.MESSAGE_RECEIVE_TIME, + Long.toString(System.currentTimeMillis())) + .putExtra(MIPushNotificationHelper.FROM_NOTIFICATION, true); + PushMetaInfo metaInfo = container.getMetaInfo(); + + try { + if (capability.kind == Kind.SERVICE) { + targetIntent.setComponent(new ComponentName( + capability.candidate.packageName, capability.candidate.className)); + if (metaInfo != null) { + targetIntent.addCategory(String.valueOf(metaInfo.getNotifyId())); + } + sendAsUserInitiatedPendingIntent( + context, targetIntent, capability.kind, targetPackage, metaInfo); + return DispatchResult.SERVICE_DELIVERY_ACCEPTED; + } + // Match the Xiaomi SDK's normal broadcast contract. Keep the + // package scope so every receiver registered by the target SDK can + // participate (Agoo/vendor bridges often fan out internally), while + // the package-scoped permission lets signature/privileged receivers + // accept the replay. + sendAsUserInitiatedPendingIntent( + context, targetIntent, capability.kind, targetPackage, metaInfo); + return DispatchResult.BROADCAST_DELIVERY_ACCEPTED; + } catch (Throwable ignored) { + return DispatchResult.FAILED; + } + } + + private static void sendAsUserInitiatedPendingIntent( + Context context, + Intent targetIntent, + Kind kind, + String targetPackage, + @Nullable PushMetaInfo metaInfo) throws PendingIntent.CanceledException { + int notifyId = metaInfo == null ? 0 : metaInfo.getNotifyId(); + int requestCode = deliveryRequestCode(targetPackage, notifyId, kind); + int flags = PendingIntent.FLAG_CANCEL_CURRENT + | PendingIntent.FLAG_ONE_SHOT + | PendingIntent.FLAG_IMMUTABLE; + PendingIntent pendingIntent = kind == Kind.SERVICE + ? PendingIntent.getService(context, requestCode, targetIntent, flags) + : PendingIntent.getBroadcast(context, requestCode, targetIntent, flags); + + Bundle options = null; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + ActivityOptions activityOptions = ActivityOptions.makeBasic(); + activityOptions.setPendingIntentBackgroundActivityStartMode( + ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED); + options = activityOptions.toBundle(); + } + String requiredPermission = kind == Kind.RECEIVER + ? receiverPermission(targetPackage) : null; + pendingIntent.send(context, 0, null, null, null, requiredPermission, options); + } + + static int deliveryRequestCode(String targetPackage, int notifyId, Kind kind) { + String identity = String.valueOf(targetPackage) + ':' + notifyId + ':' + kind; + return identity.hashCode(); + } + + @Nullable + static Capability selectCapability(String targetPackage, CapabilitySource source) { + if (targetPackage == null || source == null) { + return null; + } + Candidate service = source.service(targetPackage, SDK_SERVICE_CLASS); + if (isUsable(targetPackage, service)) { + return new Capability(Kind.SERVICE, service); + } + + List receivers = source.receivers( + targetPackage, PushConstants.MIPUSH_ACTION_NEW_MESSAGE); + if (receivers == null || receivers.isEmpty()) { + return null; + } + List ordered = new ArrayList<>(receivers.size()); + for (Candidate receiver : receivers) { + if (isUsable(targetPackage, receiver)) { + ordered.add(receiver); + } + } + Collections.sort(ordered, Comparator.comparing(candidate -> candidate.className)); + for (Candidate receiver : ordered) { + return new Capability(Kind.RECEIVER, receiver); + } + return null; + } + + private static boolean isUsable(String targetPackage, @Nullable Candidate candidate) { + return candidate != null && candidate.enabled && candidate.exported + && targetPackage.equals(candidate.packageName) + && candidate.className != null && !candidate.className.isEmpty(); + } + + private static final class AndroidCapabilitySource implements CapabilitySource { + private final PackageManager packageManager; + + AndroidCapabilitySource(PackageManager packageManager) { + this.packageManager = packageManager; + } + + @Override + @SuppressWarnings("deprecation") + public Candidate service(String targetPackage, String serviceClass) { + ComponentName component = new ComponentName(targetPackage, serviceClass); + try { + ServiceInfo info = packageManager.getServiceInfo(component, 0); + return candidate(info, component); + } catch (PackageManager.NameNotFoundException ignored) { + return null; + } + } + + @Override + @SuppressWarnings("deprecation") + public List receivers(String targetPackage, String action) { + List resolved = packageManager.queryBroadcastReceivers( + new Intent(action).setPackage(targetPackage), 0); + if (resolved == null || resolved.isEmpty()) { + return Collections.emptyList(); + } + List candidates = new ArrayList<>(resolved.size()); + for (ResolveInfo resolveInfo : resolved) { + ActivityInfo info = resolveInfo == null ? null : resolveInfo.activityInfo; + if (info != null) { + candidates.add(candidate(info, + new ComponentName(info.packageName, info.name))); + } + } + return candidates; + } + + private Candidate candidate(ComponentInfo info, ComponentName component) { + boolean applicationEnabled = info.applicationInfo == null + || info.applicationInfo.enabled; + return new Candidate(info.packageName, info.name, + applicationEnabled && isComponentEnabled(info, component), info.exported); + } + + private boolean isComponentEnabled(ComponentInfo info, ComponentName component) { + int setting = packageManager.getComponentEnabledSetting(component); + if (setting == PackageManager.COMPONENT_ENABLED_STATE_ENABLED) { + return true; + } + if (setting == PackageManager.COMPONENT_ENABLED_STATE_DISABLED + || setting == PackageManager.COMPONENT_ENABLED_STATE_DISABLED_USER + || setting == PackageManager.COMPONENT_ENABLED_STATE_DISABLED_UNTIL_USED) { + return false; + } + return info.enabled; + } + } +} diff --git a/push/src/main/java/com/xiaomi/push/service/BackgroundActivityStartEnabler.java b/push/src/main/java/com/xiaomi/push/service/BackgroundActivityStartEnabler.java index 12a655ff1..8f5e6bb8c 100644 --- a/push/src/main/java/com/xiaomi/push/service/BackgroundActivityStartEnabler.java +++ b/push/src/main/java/com/xiaomi/push/service/BackgroundActivityStartEnabler.java @@ -46,16 +46,28 @@ public static void initialize(final Context context) { final NotificationManager nm = Objects.requireNonNull(context.getSystemService(NotificationManager.class)); String channelId = tryGetValidPushStatusChannelId(context, nm); if (channelId == null) return; - notifyPushStatusInitializing(context, channelId, nm); - scheduleCapture(nm, 5); + if (notifyPushStatusInitializing(context, channelId, nm)) { + scheduleCapture(nm, 5); + } } - private static void notifyPushStatusInitializing(Context context, String channelId, NotificationManager nm) { + private static boolean notifyPushStatusInitializing(Context context, String channelId, NotificationManager nm) { final Notification n = new Notification.Builder(context, channelId).setTimeoutAfter(5_000) // Must be long enough for all retries. .setContentTitle("Initializing...").setOngoing(true) // To avoid being cancelled before capture .setGroup(TAG).setGroupAlertBehavior(GROUP_ALERT_SUMMARY) // Effectively mute this notification .setSmallIcon(android.R.drawable.stat_notify_sync_noanim).build(); - nm.notify(TAG, 0, n); + try { + nm.notify(TAG, 0, n); + return true; + } catch (SecurityException e) { + // Some HyperOS builds reject the self-attributed bootstrap + // notification when an Xposed notification bridge is not loaded + // in system_server. It is an internal five-second capture token; + // failing closed keeps the push service alive and simply disables + // the optional background-activity-start whitelist for this run. + Log.w(TAG, "Unable to post bootstrap notification; continuing without capture", e); + return false; + } } private static @Nullable String tryGetValidPushStatusChannelId(Context context, NotificationManager nm) { diff --git a/push/src/main/java/com/xiaomi/push/service/KeyedSerialDispatcher.java b/push/src/main/java/com/xiaomi/push/service/KeyedSerialDispatcher.java new file mode 100644 index 000000000..30d16c0c7 --- /dev/null +++ b/push/src/main/java/com/xiaomi/push/service/KeyedSerialDispatcher.java @@ -0,0 +1,385 @@ +package com.xiaomi.push.service; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.Executor; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; + +/** + * Dispatches commands serially for each logical key while allowing unrelated + * keys to use the delegate executor concurrently. + * + *

The dispatcher owns the per-key queues and a bounded number of queued + * commands. A command releases its queue permit before it starts running, so a + * notification worker never waits for a permit held by its own active command. + * When no permit is available, a producer for an idle key uses a lossless + * caller-runs path; a producer for a key that is already draining waits for a + * permit, while dispatcher-worker re-entry uses an unmetered emergency entry + * to preserve ordering without deadlocking the worker.

+ * + *

The delegate is not owned by this class. Call {@link #shutdown()} or + * {@link #shutdownNow()} before shutting down the delegate. A generic + * {@link Executor} cannot report a drain runnable discarded by a direct + * {@code shutdownNow()}, so bypassing this lifecycle contract may strand a + * queued key.

+ */ +final class KeyedSerialDispatcher { + private static final long PRODUCER_WAIT_MILLIS = 100L; + + private final Executor delegate; + /** Counts queued (not active) commands that own a bounded slot. */ + private final Semaphore slots; + private final FailureHandler failureHandler; + private final Object stateLock = new Object(); + private final Map states = new HashMap<>(); + private final ThreadLocal drainDepth = new ThreadLocal<>(); + private volatile boolean closed; + + KeyedSerialDispatcher( + Executor delegate, + int maxRetainedCommands, + FailureHandler failureHandler) { + this.delegate = Objects.requireNonNull(delegate, "delegate"); + if (maxRetainedCommands <= 0) { + throw new IllegalArgumentException("maxRetainedCommands must be positive"); + } + this.slots = new Semaphore(maxRetainedCommands, true); + this.failureHandler = failureHandler; + } + + /** + * Enqueue a command for {@code key}. For queued calls, order is the order + * in which the bounded slot is acquired and the command enters the + * dispatcher. An idle key may execute inline when the queue is full; + * dispatcher-worker re-entry is ordered under the key lock without + * waiting in that case. + * + * @throws RejectedExecutionException after {@link #shutdown()} or + * {@link #shutdownNow()} has been called + */ + void execute(K key, Runnable command) { + Objects.requireNonNull(key, "key"); + Objects.requireNonNull(command, "command"); + + // Acquire the first slot and register an idle key atomically. Without + // this critical section, producer A could take the last slot and pause + // before inserting its state while producer B observes an apparently + // idle key and runs inline, reversing the same-key FIFO order. + boolean reservedSlot = false; + while (true) { + State state; + boolean schedule = false; + boolean runInline = false; + boolean retryForSlot = false; + synchronized (stateLock) { + if (closed) { + if (reservedSlot) { + slots.release(); + } + throw new RejectedExecutionException("dispatcher is shut down"); + } + + state = states.get(key); + if (state == null) { + if (reservedSlot || slots.tryAcquire()) { + state = new State(); + states.put(key, state); + state.commands.addLast(new Entry(command, true)); + state.scheduled = true; + reservedSlot = false; + schedule = true; + } else { + // Match ThreadPoolExecutor's lossless CallerRunsPolicy + // for an idle key: execute directly instead of parking + // the push ingress thread behind unrelated work. The + // key is marked draining before the command starts, so + // concurrent submissions still queue behind it in FIFO. + state = new State(); + state.scheduled = true; + state.drainClaimed = true; + state.draining = true; + state.commands.addLast(new Entry(command, false)); + states.put(key, state); + runInline = true; + } + } else { + boolean entryOwnsSlot = reservedSlot; + if (!entryOwnsSlot) { + entryOwnsSlot = slots.tryAcquire(); + } + if (!entryOwnsSlot && !isDispatcherWorker()) { + // A producer for an already-draining key waits for a + // bounded queue permit, but never while holding the + // state lock. Dispatcher-worker re-entry below uses an + // unmetered emergency entry to avoid self-deadlock. + retryForSlot = true; + } else { + state.commands.addLast(new Entry(command, entryOwnsSlot)); + reservedSlot = false; + if (!state.scheduled) { + state.scheduled = true; + schedule = true; + } + } + } + } + + if (runInline) { + drainClaimed(key, state); + return; + } + if (schedule) { + submitDrain(key, state); + return; + } + if (!retryForSlot) { + return; + } + // Only the non-worker path can reach here. Keep the acquired + // permit across the next state-lock acquisition so another + // producer cannot steal it and overtake this submission. + reservedSlot = acquireSlotForCaller(); + } + } + + /** + * Stop accepting new commands while allowing all already queued commands + * to finish on the delegate. This method does not shut down the delegate. + */ + void shutdown() { + closed = true; + } + + /** + * Stop accepting new commands and detach commands that have not started. + * Active commands are allowed to finish. The returned list contains the + * detached commands in per-key FIFO order; callers own whether/how to + * retry them. Their queue permits are released before this method returns. + */ + List shutdownNow() { + List pending = new ArrayList<>(); + synchronized (stateLock) { + closed = true; + for (State state : states.values()) { + state.cancelled = true; + Entry entry; + while ((entry = state.commands.pollFirst()) != null) { + pending.add(entry.command); + releaseSlot(entry); + } + if (!state.draining) { + // The delegate may still have the drain wrapper queued; it + // will observe cancelled and return without touching state. + state.scheduled = false; + } + } + states.entrySet().removeIf(entry -> !entry.getValue().draining); + } + return pending; + } + + private boolean acquireSlotForCaller() { + if (closed) { + throw new RejectedExecutionException("dispatcher is shut down"); + } + + // A worker must never wait for a permit: active notification work may + // be the only thing capable of releasing one. If no queue slot is + // available, retain this re-entrant command as an emergency entry. + if (isDispatcherWorker()) { + return slots.tryAcquire(); + } + + boolean interrupted = false; + while (true) { + if (closed) { + if (interrupted) { + Thread.currentThread().interrupt(); + } + throw new RejectedExecutionException("dispatcher is shut down"); + } + try { + if (slots.tryAcquire(PRODUCER_WAIT_MILLIS, TimeUnit.MILLISECONDS)) { + if (interrupted) { + Thread.currentThread().interrupt(); + } + return true; + } + } catch (InterruptedException ignored) { + interrupted = true; + } + } + } + + private boolean isDispatcherWorker() { + Integer depth = drainDepth.get(); + return depth != null && depth > 0; + } + + private void submitDrain(K key, State state) { + try { + delegate.execute(() -> drain(key, state)); + } catch (Throwable schedulingFailure) { + // Executor implementations normally throw before accepting the + // runnable. claimDrain() also protects against a broken executor + // which throws after accepting it, so a task is never run twice. + reportFailure(key, schedulingFailure); + if (claimDrain(state)) { + drainClaimed(key, state); + } + } + } + + private void drain(K key, State state) { + if (claimDrain(state)) { + drainClaimed(key, state); + } + } + + private boolean claimDrain(State state) { + synchronized (stateLock) { + if (state.cancelled || state.drainClaimed) { + return false; + } + state.drainClaimed = true; + state.draining = true; + return true; + } + } + + private void drainClaimed(K key, State state) { + incrementDrainDepth(); + try { + // Drain the whole currently-available queue in one worker + // invocation. New entries remain FIFO and are handled without a + // recursive hand-off, including when CallerRunsPolicy is active. + while (true) { + Entry entry; + synchronized (stateLock) { + entry = state.cancelled ? null : state.commands.pollFirst(); + if (entry == null) { + state.draining = false; + state.scheduled = false; + states.remove(key, state); + return; + } + } + + // Active work is deliberately not counted against the queue + // bound. This prevents a worker from waiting for its own + // completion before it can enqueue a re-entrant command. + releaseSlot(entry); + try { + entry.command.run(); + } catch (Throwable failure) { + // One malformed payload must not prevent later states for + // the same notification key from being delivered. + reportFailure(key, failure); + } + } + } finally { + decrementDrainDepth(); + // Keep state/queue cleanup defensive if an unexpected failure + // occurs outside command.run() (for example a VM-level Error). + synchronized (stateLock) { + if (state.draining) { + state.draining = false; + state.scheduled = false; + clearPendingLocked(state); + states.remove(key, state); + } + } + } + } + + private void clearPendingLocked(State state) { + Entry entry; + while ((entry = state.commands.pollFirst()) != null) { + releaseSlot(entry); + } + } + + private void incrementDrainDepth() { + Integer depth = drainDepth.get(); + drainDepth.set(depth == null ? 1 : depth + 1); + } + + private void decrementDrainDepth() { + Integer depth = drainDepth.get(); + if (depth == null || depth <= 1) { + drainDepth.remove(); + } else { + drainDepth.set(depth - 1); + } + } + + private void releaseSlot(Entry entry) { + if (entry.ownsSlot) { + entry.ownsSlot = false; + slots.release(); + } + } + + private void reportFailure(K key, Throwable failure) { + if (failureHandler == null) { + return; + } + try { + failureHandler.onFailure(key, failure); + } catch (Throwable ignored) { + // Failure reporting must not break queue progress. + } + } + + // Package-private test visibility keeps production API surface small. + int activeKeyCountForTest() { + synchronized (stateLock) { + return states.size(); + } + } + + // Package-private test visibility. Running commands have already been + // removed from their queue, so this is primarily useful for leak checks. + int queuedCommandCountForTest() { + synchronized (stateLock) { + int count = 0; + for (State state : states.values()) { + count += state.commands.size(); + } + return count; + } + } + + // Package-private test visibility for shutdown permit-leak checks. + int availableSlotsForTest() { + return slots.availablePermits(); + } + + private static final class Entry { + final Runnable command; + boolean ownsSlot; + + Entry(Runnable command, boolean ownsSlot) { + this.command = command; + this.ownsSlot = ownsSlot; + } + } + + private static final class State { + final ArrayDeque commands = new ArrayDeque<>(); + boolean scheduled; + boolean drainClaimed; + boolean draining; + boolean cancelled; + } + + interface FailureHandler { + void onFailure(K key, Throwable failure); + } +} diff --git a/push/src/main/java/com/xiaomi/push/service/MIPushEventProcessorAspect.java b/push/src/main/java/com/xiaomi/push/service/MIPushEventProcessorAspect.java index e2305a84f..59a1359ae 100644 --- a/push/src/main/java/com/xiaomi/push/service/MIPushEventProcessorAspect.java +++ b/push/src/main/java/com/xiaomi/push/service/MIPushEventProcessorAspect.java @@ -96,19 +96,23 @@ public boolean shouldSendBroadcast( final ProceedingJoinPoint joinPoint, XMPushService pushService, String packageName, XmPushActionContainer container, PushMetaInfo metaInfo) throws Throwable { + XmPushActionContainer decorated = + MIPushEventProcessorAspect.decoratedContainer(packageName, container); + // The SDK's original check and our final decision must observe the same + // real-target configuration, including for wrapper containers. + AppInfoUtilsAspect.setLastMetaInfo(decorated.metaInfo); joinPoint.proceed(); - if (container.action == ActionType.Registration) { - return true; - } - if (container.packageName.startsWith("com.mi.") - || container.packageName.startsWith("com.miui.") - || container.packageName.startsWith("com.xiaomi.")) { + if (bypassesAppAliveCheck(container.action)) { return true; } - XmPushActionContainer decorated = MIPushEventProcessorAspect.decoratedContainer(container.packageName, container); return AppInfoUtilsAspect.shouldSendBroadcast(pushService, packageName, decorated.metaInfo); } + /** Registration is protocol control traffic; package names never bypass app-alive policy. */ + static boolean bypassesAppAliveCheck(ActionType action) { + return action == ActionType.Registration; + } + public void processMIPushMessage(final JoinPoint joinPoint, XMPushService pushService, byte[] decryptedContent, long packetBytesLen) { logger.d(joinPoint.getSignature()); diff --git a/push/src/main/java/com/xiaomi/push/service/MyMIPushNotificationHelper.java b/push/src/main/java/com/xiaomi/push/service/MyMIPushNotificationHelper.java index 122a8a4ec..3b5a25292 100644 --- a/push/src/main/java/com/xiaomi/push/service/MyMIPushNotificationHelper.java +++ b/push/src/main/java/com/xiaomi/push/service/MyMIPushNotificationHelper.java @@ -3,10 +3,12 @@ import static com.xiaomi.push.service.MIPushNotificationHelper.FROM_NOTIFICATION; import static com.xiaomi.push.service.MIPushNotificationHelper.getTargetPackage; import static com.xiaomi.push.service.MIPushNotificationHelper.isBusinessMessage; +import static com.xiaomi.push.service.MyNotificationIconHelper.KiB; import static com.xiaomi.push.service.MyNotificationIconHelper.MiB; import static com.xiaomi.xmsf.push.notification.NotificationController.getBitmapFromUri; import static com.xiaomi.xmsf.push.notification.NotificationController.getLargeIcon; import static com.xiaomi.xmsf.push.notification.NotificationController.getNotificationManagerEx; +import static com.xiaomi.xmsf.push.notification.NotificationController.prepareLargeIconForNotification; import static com.xiaomi.xmsf.push.notification.NotificationController.roundLargeIconIfConfigured; import android.annotation.TargetApi; @@ -23,6 +25,8 @@ import android.os.Build; import android.os.Bundle; import android.os.PowerManager; +import android.os.Process; +import android.os.SystemClock; import android.service.notification.StatusBarNotification; import android.text.TextUtils; import android.widget.Toast; @@ -40,23 +44,34 @@ import com.nihility.Global; import com.nihility.XMPushUtils; import com.nihility.notification.NotificationManagerEx; +import com.nihility.utils.NotificationReplayMarker; import com.xiaomi.channel.commonutils.android.AppInfoUtils; import com.xiaomi.channel.commonutils.reflect.JavaCalls; import com.xiaomi.mipush.sdk.PushMessageProcessor; import com.xiaomi.push.sdk.MyPushMessageHandler; import com.xiaomi.xmpush.thrift.PushMetaInfo; +import com.xiaomi.xmpush.thrift.PushMessage; +import com.xiaomi.xmpush.thrift.XmPushActionSendMessage; import com.xiaomi.xmpush.thrift.XmPushActionContainer; import com.xiaomi.xmsf.R; +import com.xiaomi.xmsf.push.notification.FocusNotificationSafety; import com.xiaomi.xmsf.push.notification.NotificationController; import com.xiaomi.xmsf.push.utils.Configurations; import com.xiaomi.xmsf.push.utils.IconConfigurations; -import com.xiaomi.xmsf.push.utils.PackageConfig; +import com.xiaomi.xmsf.push.utils.RegSecUtils; import com.xiaomi.xmsf.utils.ConfigCenter; +import com.xiaomi.xmsf.utils.ConvertUtils; + +import org.apache.thrift.TBase; +import org.json.JSONArray; +import org.json.JSONObject; import java.net.MalformedURLException; import java.net.URISyntaxException; import java.net.URL; +import java.util.Locale; import java.util.Map; +import java.util.Objects; import java.util.Set; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -73,6 +88,7 @@ */ public class MyMIPushNotificationHelper { + public static final String CLASS_NAME_PUSH_MESSAGE_HANDLER = "com.xiaomi.mipush.sdk.PushMessageHandler"; private static Logger logger = XLog.tag("MyNotificationHelper").build(); @@ -86,9 +102,12 @@ public class MyMIPushNotificationHelper { private static final int NOTIFICATION_ACTION_BUTTON_PLACE_LEFT = 1; private static final int NOTIFICATION_ACTION_BUTTON_PLACE_MID = 2; private static final int NOTIFICATION_ACTION_BUTTON_PLACE_RIGHT = 3; - private static final String NOTIFICATION_STYLE_BIG_PICTURE = "2"; - private static final String NOTIFICATION_STYLE_BIG_PICTURE_URI = "notification_bigPic_uri"; - private static final String NOTIFICATION_STYLE_BIG_TEXT = "1"; + private static final int NOTIFICATION_ACTION_BUTTON_PLACE_COLORFUL = 4; + private static final String NOTIFICATION_COLORFUL_BUTTON_INTENT_CLASS = "notification_colorful_button_intent_class"; + private static final String NOTIFICATION_COLORFUL_BUTTON_INTENT_URI = "notification_colorful_button_intent_uri"; + private static final String NOTIFICATION_COLORFUL_BUTTON_NOTIFY_EFFECT = "notification_colorful_button_notify_effect"; + private static final String NOTIFICATION_COLORFUL_BUTTON_TEXT = "notification_colorful_button_text"; + private static final String NOTIFICATION_COLORFUL_BUTTON_WEB_URI = "notification_colorful_button_web_uri"; private static final String NOTIFICATION_STYLE_BUTTON_LEFT_INTENT_CLASS = "notification_style_button_left_intent_class"; private static final String NOTIFICATION_STYLE_BUTTON_LEFT_INTENT_URI = "notification_style_button_left_intent_uri"; private static final String NOTIFICATION_STYLE_BUTTON_LEFT_NAME = "notification_style_button_left_name"; @@ -105,48 +124,206 @@ public class MyMIPushNotificationHelper { private static final String NOTIFICATION_STYLE_BUTTON_RIGHT_NOTIFY_EFFECT = "notification_style_button_right_notify_effect"; private static final String NOTIFICATION_STYLE_BUTTON_RIGHT_WEB_URI = "notification_style_button_right_web_uri"; private static final String NOTIFICATION_STYLE_TYPE = "notification_style_type"; - + /** + * Configuration files may intentionally replace a sender-declared click + * route by setting this key to the literal value {@code true}. Without this + * opt-in, a rewritten route is treated as presentation-only configuration + * whenever the original sender route is still safe and usable. + */ + static final String ALLOW_CLICK_ROUTE_REWRITE = + "__mi_push_allow_click_route_rewrite"; + private static final String[] CLICK_ROUTE_EXTRA_KEYS = { + PushConstants.EXTRA_PARAM_NOTIFY_EFFECT, + PushConstants.EXTRA_PARAM_INTENT_URI, + PushConstants.EXTRA_PARAM_CLASS_NAME, + PushConstants.EXTRA_PARAM_WEB_URI, + PushConstants.EXTRA_PARAM_INTENT_FLAG + }; + private static final StyleActionKeys LEFT_ACTION_KEYS = new StyleActionKeys( + NOTIFICATION_STYLE_BUTTON_LEFT_NOTIFY_EFFECT, + NOTIFICATION_STYLE_BUTTON_LEFT_INTENT_URI, + NOTIFICATION_STYLE_BUTTON_LEFT_INTENT_CLASS, + NOTIFICATION_STYLE_BUTTON_LEFT_WEB_URI); + private static final StyleActionKeys MID_ACTION_KEYS = new StyleActionKeys( + NOTIFICATION_STYLE_BUTTON_MID_NOTIFY_EFFECT, + NOTIFICATION_STYLE_BUTTON_MID_INTENT_URI, + NOTIFICATION_STYLE_BUTTON_MID_INTENT_CLASS, + NOTIFICATION_STYLE_BUTTON_MID_WEB_URI); + private static final StyleActionKeys RIGHT_ACTION_KEYS = new StyleActionKeys( + NOTIFICATION_STYLE_BUTTON_RIGHT_NOTIFY_EFFECT, + NOTIFICATION_STYLE_BUTTON_RIGHT_INTENT_URI, + NOTIFICATION_STYLE_BUTTON_RIGHT_INTENT_CLASS, + NOTIFICATION_STYLE_BUTTON_RIGHT_WEB_URI); + private static final StyleActionKeys COLORFUL_ACTION_KEYS = new StyleActionKeys( + NOTIFICATION_COLORFUL_BUTTON_NOTIFY_EFFECT, + NOTIFICATION_COLORFUL_BUTTON_INTENT_URI, + NOTIFICATION_COLORFUL_BUTTON_INTENT_CLASS, + NOTIFICATION_COLORFUL_BUTTON_WEB_URI); private static boolean tryLoadConfigurations = false; - private static ExecutorService executorService = Executors.newFixedThreadPool(3); + private static final java.util.concurrent.atomic.AtomicInteger NOTIFICATION_THREAD_COUNT = + new java.util.concurrent.atomic.AtomicInteger(1); + /** + * Keep the hand-off queue deliberately small because each queued task retains a decrypted + * push payload. CallerRunsPolicy remains the lossless back-pressure mechanism: when this + * bound is reached the producer performs the notification work itself instead of dropping it. + */ + static final int NOTIFICATION_QUEUE_CAPACITY = 16; + private static final java.util.concurrent.ThreadPoolExecutor executorService = createNotificationExecutor(); + /** + * Keep updates for one Android notification identity ordered while still + * allowing unrelated applications/ids to use the three worker threads in + * parallel. The dispatcher bounds payload retention to the same queue + * budget as the underlying executor. + */ + private static final KeyedSerialDispatcher notificationDispatcher = + new KeyedSerialDispatcher<>( + executorService, + NOTIFICATION_QUEUE_CAPACITY, + (key, failure) -> logger.e( + "Notification task failed for " + key, + failure)); + + /** + * Explicit wake operations are optional side effects. Keep a short + * per-package gate so bursty pushes cannot repeatedly reacquire a + * screen-bright wake lock, while notification publication continues + * independently in the dispatch pipeline. + */ + private static final WakeScreenThrottle WAKE_SCREEN_THROTTLE = + new WakeScreenThrottle(SystemClock::elapsedRealtime); + + public static java.util.concurrent.ThreadPoolExecutor getNotificationExecutor() { + return executorService; + } + + private static java.util.concurrent.ThreadPoolExecutor createNotificationExecutor() { + java.util.concurrent.ThreadPoolExecutor executor = new java.util.concurrent.ThreadPoolExecutor( + 3, + 3, + 30L, + java.util.concurrent.TimeUnit.SECONDS, + new java.util.concurrent.ArrayBlockingQueue<>(NOTIFICATION_QUEUE_CAPACITY), + r -> { + Thread t = new Thread(() -> { + try { + Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND); + } catch (Throwable ignored) { + } + r.run(); + }, "mipush-notification-" + NOTIFICATION_THREAD_COUNT.getAndIncrement()); + t.setDaemon(false); + return t; + }, + new java.util.concurrent.ThreadPoolExecutor.CallerRunsPolicy() + ); + executor.allowCoreThreadTimeOut(true); + return executor; + } /** * @see `MIPushNotificationHelper`#notifyPushMessage */ public static void notifyPushMessage(Context context, byte[] decryptedContent) { XmPushActionContainer container = XMPushUtils.packToContainer(decryptedContent); - AppInfoUtils.AppNotificationOp notificationOp = AppInfoUtils.getAppNotificationOp(context, getTargetPackage(container), true); + String targetPackage = publishPackageName(container); + AppInfoUtils.AppNotificationOp notificationOp = + AppInfoUtils.getAppNotificationOp(context, targetPackage, true); if (notificationOp == AppInfoUtils.AppNotificationOp.NOT_ALLOWED) { - logger.w("Do not notify because user block " + getTargetPackage(container) + "'s notification"); + logger.w("Do not notify because user block " + targetPackage + "'s notification"); } else { loadConfigurationsOnce(context); - handleNotificationByConfigurations(context, decryptedContent, container.getPackageName(), container); + // The SDK uses the wrapper package for some system-delivered + // messages and carries the real client in miui_package_name. + // Configuration matching must use the same target identity that + // will later own the published notification. + handleNotificationByConfigurations( + context, decryptedContent, publishPackageName(container), container); } } private static void handleNotificationByConfigurations(Context context, byte[] decryptedContent, String packageName, XmPushActionContainer container) { + Context appContext = context.getApplicationContext() != null ? context.getApplicationContext() : context; + Set operations = null; try { - Set operations = Configurations.getInstance().handle(packageName, container); + operations = Configurations.getInstance().handle(packageName, container); + } catch (Exception e) { + logger.e(e.getLocalizedMessage(), e); + } - if (operations.contains(PackageConfig.OPERATION_WAKE)) { - wakeScreen(context, packageName); - } - if (!operations.contains(PackageConfig.OPERATION_IGNORE)) { - executorService.execute(() -> { + NotificationDispatchPipeline.DispatchPlan plan = + NotificationDispatchPipeline.planFromOperations(operations); + NotificationDispatchPipeline.dispatch( + plan, + () -> wakeScreen(appContext, packageName), + // Configuration rules may rewrite the package or notify id. + // Derive the Android identity only after those rewrites have + // completed so every update reaches the queue that will + // actually publish it. + () -> notificationDispatcher.execute(notificationKeyFor(container), () -> { try { - doNotifyPushMessage(context, container, decryptedContent); + doNotifyPushMessage(appContext, container, decryptedContent); } catch (Exception e) { logger.e(e.getLocalizedMessage(), e); } - }); + }), + () -> MyPushMessageHandler.startService(appContext, container, decryptedContent), + (stage, exception) -> logger.e( + "Notification dispatch stage failed: " + stage, + exception)); + } + + /** + * Build the key from the exact package/tag/id tuple used by the current + * publish path. Keeping this derivation in one place prevents a future + * target-package attribution change from silently creating a second queue + * for the same Android notification. + */ + private static NotificationKey notificationKeyFor(XmPushActionContainer container) { + String packageName = publishPackageName(container); + return new NotificationKey( + packageName, + getNotificationTag(packageName), + getNotificationId(container)); + } + + private static String publishPackageName(XmPushActionContainer container) { + if (container == null) { + return ""; + } + // System-wrapper messages carry the real client in this public MiPush + // field. Read it before the SDK helper so a hook/aspect failure cannot + // misattribute the notification to com.xiaomi.xmsf. + if ("com.xiaomi.xmsf".equals(container.getPackageName()) + && container.getMetaInfo() != null + && container.getMetaInfo().getExtra() != null) { + String wrappedTarget = container.getMetaInfo().getExtra().get("miui_package_name"); + if (wrappedTarget != null && !wrappedTarget.trim().isEmpty()) { + return wrappedTarget.trim(); } - if (operations.contains(PackageConfig.OPERATION_OPEN)) { - MyPushMessageHandler.startService(context, container, decryptedContent); + } + try { + String targetPackage = getTargetPackage(container); + if (targetPackage != null && !targetPackage.isEmpty()) { + return targetPackage; } - } catch (Exception e) { - logger.e(e.getLocalizedMessage(), e); + } catch (Throwable error) { + logger.w("Unable to derive notification publish package", error); } + if (container.getPackageName() != null && !container.getPackageName().isEmpty()) { + return container.getPackageName(); + } + return ""; + } + + /** + * Return the package that owns the rendered notification, including the + * miui_package_name target carried by system-wrapper messages. + */ + public static String getNotificationTargetPackage( + @Nullable XmPushActionContainer container) { + return publishPackageName(container); } private static void loadConfigurationsOnce(Context context) { @@ -172,20 +349,34 @@ private static void loadConfigurations(Context context, Uri configurationDirecto private static void wakeScreen(Context context, String sourcePackage) { PowerManager powerManager = (PowerManager) context.getSystemService(Context.POWER_SERVICE); + if (powerManager == null || powerManager.isInteractive()) { + return; + } + if (!WAKE_SCREEN_THROTTLE.tryAcquire(sourcePackage)) { + return; + } PowerManager.WakeLock fullWakeLock = powerManager.newWakeLock(( PowerManager.SCREEN_BRIGHT_WAKE_LOCK | PowerManager.FULL_WAKE_LOCK | PowerManager.ACQUIRE_CAUSES_WAKEUP ), "xmsf: configurations of " + sourcePackage); - fullWakeLock.acquire(10000); + // Waking the panel is an explicit per-app configuration. Keep the + // pulse short so a notification cannot hold a third-party ROM awake + // for ten seconds after SystemUI has already rendered it. + fullWakeLock.acquire(5_000L); } private static Notification findActiveNotification(String packageName, int notificationId) { StatusBarNotification[] notifications = getNotificationManagerEx().getActiveNotifications(packageName); - assert notifications != null; + if (notifications == null) { + return null; + } for (StatusBarNotification notification : notifications) { - if (notification.getId() == notificationId) { - return notification.getNotification(); + if (notification != null && notification.getId() == notificationId) { + Notification activeNotification = notification.getNotification(); + if (activeNotification != null) { + return activeNotification; + } } } return null; @@ -218,7 +409,8 @@ private static void doNotifyPushMessage(Context context, XmPushActionContainer c NotificationInfo result = getNotificationFor(context, container, decryptedContent); - NotificationController.publish(context, metaInfo, result.notificationId, container.getPackageName(), result.notificationBuilder); + NotificationController.publish(context, metaInfo, result.notificationId, + publishPackageName(container), result.notificationBuilder); } private static void logPushMessage(PushMetaInfo metaInfo) { @@ -231,10 +423,11 @@ private static void logPushMessage(PushMetaInfo metaInfo) { @NonNull private static NotificationInfo getNotificationFor(Context context, XmPushActionContainer container, byte[] decryptedContent) { PushMetaInfo metaInfo = container.getMetaInfo(); - String packageName = container.getPackageName(); + String packageName = publishPackageName(container); Context pkgCtx = getPackageContext(context, packageName); - NotificationCompat.MessagingStyle.Message message = createMessage(context, container, pkgCtx); + NotificationCompat.MessagingStyle.Message message = createMessage( + context, container, pkgCtx, packageName); CustomConfiguration custom = XMPushUtils.getConfiguration(metaInfo); boolean useMessagingStyle = message != null && custom.useMessagingStyle(false); @@ -245,7 +438,8 @@ private static NotificationInfo getNotificationFor(Context context, XmPushAction if (useMessagingStyle) { notificationBuilder = messagingStyleNotificationBuilder(context, container, notificationId, message, pkgCtx); } else { - notificationBuilder = normalStyleNotificationBuilder(context, container.getMetaInfo()); + notificationBuilder = normalStyleNotificationBuilder( + context, packageName, container.getMetaInfo()); } if (metaInfo.getExtra() != null) { @@ -253,8 +447,16 @@ private static NotificationInfo getNotificationFor(Context context, XmPushAction } addDebugAction(context, container, decryptedContent, metaInfo, packageName, notificationBuilder); + if (useMessagingStyle) { + // Xiaomi's SystemUI uses this documented MiPush hint for the heads-up + // affordance. A conversation with a validated Activity click can then + // be dragged into the target application's small window. + notificationBuilder.getExtras().putBoolean("miui.enableFloat", true); + notificationBuilder.setCategory(Notification.CATEGORY_MESSAGE); + } + notificationBuilder.setWhen(metaInfo.getMessageTs()); - notificationBuilder.setShowWhen(true); + notificationBuilder.setShowWhen(custom.notificationShowWhen(true)); String group = getGroupName(context, container); notificationBuilder.setGroup(group); @@ -263,12 +465,19 @@ private static NotificationInfo getNotificationFor(Context context, XmPushAction intentExtra.putExtra(Constants.INTENT_NOTIFICATION_ID, notificationId); intentExtra.putExtra(Constants.INTENT_NOTIFICATION_GROUP, notificationBuilder.build().getGroup()); - PendingIntent localPendingIntent = getClickedPendingIntent( - context, container, decryptedContent, notificationId, intentExtra.getExtras()); - - if (localPendingIntent != null) { - notificationBuilder.setContentIntent(localPendingIntent); - carryPendingIntentForTemporarilyWhitelisted(context, container, notificationBuilder); + ClickPendingIntent clickPendingIntent = getClickedPendingIntent( + context, container, decryptedContent, notificationId, intentExtra.getExtras(), + useMessagingStyle); + + if (clickPendingIntent != null) { + notificationBuilder.setContentIntent(clickPendingIntent.pendingIntent); + // The temporary-whitelist service PendingIntent is only needed for + // the legacy Service click path. Carrying it alongside an Activity + // click can make HyperOS wake the target service and the target + // Activity together, producing a visible hand-off pause. + if (shouldCarryTemporaryWhitelist(clickPendingIntent.activity)) { + carryPendingIntentForTemporarilyWhitelisted(context, container, notificationBuilder); + } } return new NotificationInfo(notificationId, notificationBuilder); } @@ -283,32 +492,120 @@ public NotificationInfo(int notificationId, NotificationCompat.Builder notificat } } + /** + * Immutable Android notification identity used by the keyed dispatcher. + * The tuple mirrors NotificationManagerEx.notify(package, tag, id). + */ + static final class NotificationKey { + final String packageName; + final String tag; + final int id; + + NotificationKey(String packageName, String tag, int id) { + this.packageName = packageName == null ? "" : packageName; + this.tag = tag == null ? "" : tag; + this.id = id; + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof NotificationKey)) { + return false; + } + NotificationKey that = (NotificationKey) other; + return id == that.id + && packageName.equals(that.packageName) + && tag.equals(that.tag); + } + + @Override + public int hashCode() { + int result = packageName.hashCode(); + result = 31 * result + tag.hashCode(); + result = 31 * result + id; + return result; + } + + @Override + public String toString() { + return packageName + "/" + tag + "/" + id; + } + } + private static Context getPackageContext(Context context, String packageName) { Context pkgCtx = context; - if (NotificationManagerEx.isHooked) { - try { - pkgCtx = context.createPackageContext(packageName, 0); - } catch (PackageManager.NameNotFoundException e) { - e.printStackTrace(); - } + try { + // Shortcut/person icons and resource lookup must use the client + // package even when the optional MIUI notification hook is absent. + pkgCtx = context.createPackageContext(packageName, Context.CONTEXT_IGNORE_SECURITY); + } catch (PackageManager.NameNotFoundException ignored) { } return pkgCtx; } @NonNull - private static NotificationCompat.Builder normalStyleNotificationBuilder(Context context, PushMetaInfo metaInfo) { - String title = metaInfo.getTitle(); - String description = metaInfo.getDescription(); + private static NotificationCompat.Builder normalStyleNotificationBuilder( + Context context, String packageName, PushMetaInfo metaInfo) { + CustomConfiguration configuration = XMPushUtils.getConfiguration(metaInfo); + String focusParameter = configuration.focusParam(null); + String fallbackTitle = packageName; + try { + CharSequence appName = Global.ApplicationNameCache().getAppName(context, packageName); + if (appName != null && appName.length() > 0) { + fallbackTitle = appName.toString(); + } + } catch (Throwable ignored) { + } + FocusNotificationSafety.ResolvedContent resolved = + FocusNotificationSafety.resolveReadableContent( + metaInfo.getTitle(), metaInfo.getDescription(), focusParameter, + fallbackTitle, "New notification"); + String title = resolved.title(); + String description = resolved.body(); + CustomConfiguration.NotificationStyle notificationStyle = + configuration.notificationStyle(); + // On non-Xiaomi ROMs the private miui.focus.* renderer is deliberately + // disabled. Keep the original, portable focus presentation visible by + // expanding the readable body whenever a valid focus payload exists, + // even when the text is shorter than the ordinary BigText threshold. + boolean hasPortableFocusPayload = configuration.focusNotificationPayload().isUsable(); Bitmap bigPic = getBigPic(context, metaInfo); + if (notificationStyle == CustomConfiguration.NotificationStyle.COLORFUL) { + bigPic = getBitmapFromUri(context, + configuration.notificationColorfulBackgroundImageUri(null), 1 * MiB); + } else if (notificationStyle == CustomConfiguration.NotificationStyle.BANNER) { + bigPic = getBitmapFromUri(context, + configuration.notificationBannerImageUri(null), 1 * MiB); + } NotificationCompat.Builder notificationBuilder = new NotificationCompat.Builder(context); + if (notificationStyle == CustomConfiguration.NotificationStyle.BANNER) { + Bitmap bannerIcon = getBitmapFromUri(context, + configuration.notificationBannerIconUri(null), 200 * KiB); + if (bannerIcon != null) { + // MIUI renders this inside its private banner layout. A large + // icon is the closest portable representation on other ROMs. + notificationBuilder.setLargeIcon(bannerIcon); + } + } + // Colorful and Banner are private MIUI layouts. On other ROMs, their + // published background image is represented with the portable style. if (bigPic != null) { NotificationCompat.BigPictureStyle style = new NotificationCompat.BigPictureStyle(); style.bigPicture(bigPic); style.setBigContentTitle(title); + String imageDescription = configuration.imageDescription(null); + if (!TextUtils.isEmpty(imageDescription)) { + style.setContentDescription(imageDescription); + } notificationBuilder.setStyle(style); - } else if (description.length() > NOTIFICATION_BIG_STYLE_MIN_LEN) { + } else if (notificationStyle == CustomConfiguration.NotificationStyle.BIG_TEXT + || description.length() > NOTIFICATION_BIG_STYLE_MIN_LEN + || hasPortableFocusPayload) { NotificationCompat.BigTextStyle style = new NotificationCompat.BigTextStyle(); style.bigText(description); style.setBigContentTitle(title); @@ -316,8 +613,12 @@ private static NotificationCompat.Builder normalStyleNotificationBuilder(Context } String[] titleAndDesp = determineTitleAndDespByDIP(context, metaInfo); - notificationBuilder.setContentTitle(titleAndDesp[0]); - notificationBuilder.setContentText(titleAndDesp[1]); + FocusNotificationSafety.ResolvedContent dipResolved = + FocusNotificationSafety.resolveReadableContent( + titleAndDesp[0], titleAndDesp[1], focusParameter, + title, description); + notificationBuilder.setContentTitle(dipResolved.title()); + notificationBuilder.setContentText(dipResolved.body()); return notificationBuilder; } @@ -332,7 +633,7 @@ private static Bitmap getBigPic(Context context, PushMetaInfo metaInfo) { @NonNull private static NotificationCompat.Builder messagingStyleNotificationBuilder( Context context, XmPushActionContainer container, int notificationId, NotificationCompat.MessagingStyle.Message message, Context pkgCtx) { - String packageName = container.getPackageName(); + String packageName = publishPackageName(container); NotificationCompat.Builder messagingBuilder = addToExistingMessageNotification(context, packageName, notificationId, message); if (messagingBuilder != null) { return messagingBuilder; @@ -344,10 +645,14 @@ private static NotificationCompat.Builder messagingStyleNotificationBuilder( @NonNull private static NotificationCompat.Builder createMessageStyleNotificationBuilder(Context context, XmPushActionContainer container, NotificationCompat.MessagingStyle.Message message, Context pkgCtx, String packageName) { PushMetaInfo metaInfo = container.getMetaInfo(); - Person group = getGroupFor(context, metaInfo).build(); + Person group = getGroupFor(context, metaInfo, packageName).build(); NotificationCompat.Builder notificationBuilder = new NotificationCompat.Builder(context); attachMessagingStyle(message, group, metaInfo, notificationBuilder); + // The official large-icon URI is applied later by NotificationController. + // When it is absent (for example QQ QZone events), keep the same local + // conversation icon visible in the ordinary notification surface. + setLargeIconFromPerson(notificationBuilder, group); addShortcutToEnableMessagingStyle(context, container, pkgCtx, packageName, group, notificationBuilder); return notificationBuilder; } @@ -389,24 +694,28 @@ private static Intent getIntentForMessagingStyle(Context context, XmPushActionCo } @Nullable - private static NotificationCompat.MessagingStyle.Message createMessage(Context context, XmPushActionContainer container, Context pkgCtx) { + private static NotificationCompat.MessagingStyle.Message createMessage( + Context context, XmPushActionContainer container, Context pkgCtx, + String packageName) { PushMetaInfo metaInfo = container.metaInfo; CustomConfiguration custom = XMPushUtils.getConfiguration(metaInfo); String senderMessage = custom.conversationMessage(null); if (senderMessage == null) { return null; } - return createMessage(context, pkgCtx, metaInfo, senderMessage); + return createMessage(context, pkgCtx, metaInfo, senderMessage, packageName); } @NonNull - private static NotificationCompat.MessagingStyle.Message createMessage(Context context, Context pkgCtx, PushMetaInfo metaInfo, String senderMessage) { + private static NotificationCompat.MessagingStyle.Message createMessage( + Context context, Context pkgCtx, PushMetaInfo metaInfo, String senderMessage, + String packageName) { boolean atLeastP = pkgCtx != null && pkgCtx.getApplicationInfo().targetSdkVersion >= Build.VERSION_CODES.P; Person person = null; if (isGroupConversation(metaInfo) || atLeastP) { - person = getPerson(context, metaInfo).build(); + person = getPerson(context, metaInfo, packageName).build(); } return new NotificationCompat.MessagingStyle.Message( senderMessage, metaInfo.getMessageTs(), person); @@ -418,15 +727,19 @@ private static boolean isGroupConversation(PushMetaInfo metaInfo) { } @NonNull - private static Person.Builder getGroupFor(Context context, PushMetaInfo metaInfo) { + private static Person.Builder getGroupFor( + Context context, PushMetaInfo metaInfo, String packageName) { CustomConfiguration custom = XMPushUtils.getConfiguration(metaInfo); String conversation = custom.conversationTitle(null); String conversationId = custom.conversationId(null); String conversationIcon = custom.conversationIcon(null); + if (TextUtils.isEmpty(conversationIcon)) { + conversationIcon = custom.notificationLargeIconUri(null); + } Person.Builder personBuilder = isGroupConversation(metaInfo) ? new Person.Builder() : - getPerson(context, metaInfo); + getPerson(context, metaInfo, packageName); if (conversation != null) { personBuilder.setName(conversation); } else if (personBuilder.build().getName() == null) { @@ -443,11 +756,15 @@ private static Person.Builder getGroupFor(Context context, PushMetaInfo metaInfo } @NonNull - private static Person.Builder getPerson(Context context, PushMetaInfo metaInfo) { + private static Person.Builder getPerson( + Context context, PushMetaInfo metaInfo, String packageName) { CustomConfiguration custom = XMPushUtils.getConfiguration(metaInfo); String sender = custom.conversationSender(null); String senderId = custom.conversationSenderId(null); String senderIcon = custom.conversationSenderIcon(null); + if (TextUtils.isEmpty(senderIcon)) { + senderIcon = custom.notificationLargeIconUri(null); + } String textIcon = custom.textIcon(null); Person.Builder personBuilder = new Person.Builder().setName(sender); @@ -463,23 +780,74 @@ private static Person.Builder getPerson(Context context, PushMetaInfo metaInfo) roundLargeIconIfConfigured(metaInfo, ImageUtils.INSTANCE.textToBitmap(textIcon, 72, 0xFF003E6F, Color.WHITE) ))); + } else if (!isGroupConversation(metaInfo) + && shouldUseApplicationIconFallback(senderIcon)) { + Bitmap applicationIcon = getApplicationIconForConversation( + context, packageName, metaInfo); + if (applicationIcon != null) { + personBuilder.setIcon(IconCompat.createWithBitmap(applicationIcon)); + } } return personBuilder; } + /** + * A sender URI is authoritative. Only a missing URI may use the local + * application icon; an empty URI is treated as missing because config + * replacement can intentionally clear an older value. + */ + static boolean shouldUseApplicationIconFallback(@Nullable String senderIcon) { + return senderIcon == null || senderIcon.trim().isEmpty(); + } + + @Nullable + private static Bitmap getApplicationIconForConversation( + Context context, String packageName, PushMetaInfo metaInfo) { + if (context == null || TextUtils.isEmpty(packageName)) { + return null; + } + try { + Bitmap icon = Global.IconCache().getRawIconBitmap(context, packageName); + if (icon != null && !icon.isRecycled()) { + return prepareLargeIconForNotification(context, metaInfo, icon); + } + } catch (Throwable error) { + logger.w("Unable to resolve local conversation application icon", error); + } + return null; + } + + private static void setLargeIconFromPerson( + NotificationCompat.Builder notificationBuilder, Person person) { + if (notificationBuilder == null || person == null || person.getIcon() == null) { + return; + } + try { + Bitmap icon = person.getIcon().getBitmap(); + if (icon != null && !icon.isRecycled()) { + notificationBuilder.setLargeIcon(icon); + } + } catch (Throwable ignored) { + // Resource/URI-backed Person icons are handled by SystemUI. Only + // bitmap-backed icons can be copied to Notification.largeIcon. + } + } + private static void carryPendingIntentForTemporarilyWhitelisted(Context xmPushService, XmPushActionContainer buildContainer, NotificationCompat.Builder localBuilder) { PushMetaInfo metaInfo = buildContainer.getMetaInfo(); // Also carry along the target PendingIntent, whose target will get temporarily whitelisted for background-activity-start upon sent. - final Intent targetIntent = buildTargetIntentWithoutExtras(buildContainer.getPackageName(), metaInfo); - final PendingIntent pi = PendingIntent.getService(xmPushService, 0, targetIntent, PendingIntent.FLAG_UPDATE_CURRENT); + final Intent targetIntent = buildTargetIntentWithoutExtras( + publishPackageName(buildContainer), metaInfo); + final PendingIntent pi = PendingIntent.getService(xmPushService, 0, targetIntent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); localBuilder.getExtras().putParcelable("mipush.target", pi); } public static int getNotificationId(XmPushActionContainer container) { final PushMetaInfo metaInfo = container.getMetaInfo(); String id = metaInfo.isSetNotifyId() ? String.valueOf(metaInfo.getNotifyId()) : metaInfo.getId(); - String idWithPackage = getTargetPackage(container) + "_" + id; + String idWithPackage = publishPackageName(container) + "_" + id; return idWithPackage.hashCode(); } @@ -488,24 +856,40 @@ public static String getNotificationTag(String packageName) { } public static String getNotificationTag(XmPushActionContainer container) { - return getNotificationTag(container.packageName); + return getNotificationTag(publishPackageName(container)); } private static String getGroupName(Context xmPushService, XmPushActionContainer buildContainer) { PushMetaInfo metaInfo = buildContainer.getMetaInfo(); - String packageName = buildContainer.getPackageName(); + String packageName = publishPackageName(buildContainer); RegisteredApplication application = RegisteredApplicationDb.getRegisteredApplication(packageName); CustomConfiguration configuration = XMPushUtils.getConfiguration(metaInfo); - String group = configuration.notificationGroup(null); - if (group != null) { + String configuredGroup = configuration.notificationGroup(null); + if (configuredGroup != null && !configuredGroup.trim().isEmpty()) { + String group = configuredGroup; group = packageName + "_" + GROUP_TYPE_MIPUSH_GROUP + "_" + group; + return group; } else if (metaInfo.passThrough == 1) { - group = packageName + "_" + GROUP_TYPE_PASS_THROUGH; + return packageName + "_" + GROUP_TYPE_PASS_THROUGH; } else { - group = packageName; + CustomConfiguration.FocusNotificationPayload focusPayload = + configuration.focusNotificationPayload(); + boolean hasDeliverableFocusPayload = + (FocusNotificationSafety.isWellFormedParameter(focusPayload.parameter()) + || FocusNotificationSafety.isWellFormedParameter( + focusPayload.customParameter()) + || !focusPayload.pictureUrls().isEmpty()); + if (FocusNotificationSafety.shouldIsolateFocusGroup( + configuredGroup, hasDeliverableFocusPayload)) { + return FocusNotificationSafety.stableFocusGroup(packageName); + } } - return group; + // This is the SDK's historical default for ordinary notifications. A + // focus payload takes the isolated branch above unless the sender gave + // an explicit official group, preventing SystemUI from folding it into + // unrelated package notifications. + return packageName; } private static void addDebugAction(Context xmPushService, XmPushActionContainer buildContainer, byte[] var1, PushMetaInfo metaInfo, String packageName, NotificationCompat.Builder localBuilder) { @@ -524,7 +908,9 @@ private static void addDebugAction(Context xmPushService, XmPushActionContainer Intent sdkIntentJump = getSdkIntent(xmPushService, buildContainer); if (sdkIntentJump != null) { - PendingIntent pendingIntent = PendingIntent.getActivity(xmPushService, 0, sdkIntentJump, PendingIntent.FLAG_UPDATE_CURRENT); + PendingIntent pendingIntent = PendingIntent.getActivity(xmPushService, 0, + sdkIntentJump, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); localBuilder.addAction(new NotificationCompat.Action(i, "SDK Intent", pendingIntent)); } } @@ -536,23 +922,31 @@ public static Intent buildTargetIntentWithoutExtras(final String pkg, final Push } private static PendingIntent openActivityPendingIntent(Context paramContext, XmPushActionContainer paramXmPushActionContainer, PushMetaInfo paramPushMetaInfo, byte[] paramArrayOfByte) { - String packageName = paramXmPushActionContainer.getPackageName(); + String packageName = publishPackageName(paramXmPushActionContainer); PackageManager packageManager = paramContext.getPackageManager(); Intent localIntent1 = packageManager.getLaunchIntentForPackage(packageName); if (localIntent1 != null) { localIntent1.addCategory(String.valueOf(paramPushMetaInfo.getNotifyId())); - return PendingIntent.getActivity(paramContext, 0, localIntent1, PendingIntent.FLAG_UPDATE_CURRENT); + return PendingIntent.getActivity(paramContext, 0, localIntent1, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); } return null; } - private static PendingIntent getClickedPendingIntent( + private static ClickPendingIntent getClickedPendingIntent( Context context, XmPushActionContainer container, byte[] decryptedContent, - int notificationId, Bundle extra) { + int notificationId, Bundle extra, boolean messagingStyle) { PushMetaInfo metaInfo = container.getMetaInfo(); if (metaInfo == null) { return null; } + String targetPackage = publishPackageName(container); + + // Resolve a safe sender-declared route before the legacy web shortcut: + // a configuration may have replaced an SDK intent with a URL, and the + // shortcut would otherwise make that replacement impossible to audit. + ClickRouteResolution restoredSenderRoute = resolveRestoredSenderClickRoute( + context, container, decryptedContent); //Jump web String urlJump = null; @@ -562,11 +956,13 @@ private static PendingIntent getClickedPendingIntent( urlJump = metaInfo.getExtra().get(PushConstants.EXTRA_PARAM_WEB_URI); } - if (!TextUtils.isEmpty(urlJump)) { + if (restoredSenderRoute == null && !TextUtils.isEmpty(urlJump)) { Intent intent = new Intent("android.intent.action.VIEW"); intent.setData(Uri.parse(urlJump)); intent.addFlags(Intent.FLAG_RECEIVER_FOREGROUND); - return PendingIntent.getActivity(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT); + return ClickPendingIntent.activity(PendingIntent.getActivity( + context, notificationId, intent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE)); } Intent intent = new Intent(); @@ -580,35 +976,307 @@ private static PendingIntent getClickedPendingIntent( intent.addCategory(String.valueOf(metaInfo.getNotifyId())); CustomConfiguration configuration = XMPushUtils.getConfiguration(metaInfo); - boolean useActivity = configuration.useClickedActivity(false); - Intent activityIntent = getSdkIntent(context, container); - if (!useActivity || activityIntent == null) { - return PendingIntent.getService(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT); + // Prefer a validated client Activity for every ordinary notification. The + // historical service PendingIntent requires a background service to call + // startActivity(), which Android 16/HyperOS may reject even though the + // notification click itself is user initiated. A package launcher is a + // safe fallback when the sender did not provide notify_effect metadata. + ClickRouteResolution clickRoute = restoredSenderRoute != null + ? restoredSenderRoute : resolveSdkClickRoute(context, container); + Intent activityIntent = clickRoute == null ? null : clickRoute.intent; + if (activityIntent == null) { + activityIntent = getLaunchIntent(context, targetPackage); + } + boolean replaySenderRoute = shouldUseReplayClickTrampoline( + NotificationReplayMarker.isMarked(container), + clickRoute != null, + clickRoute != null && clickRoute.discoveredRoute); + // Keep the setting tri-state: an absent key selects the direct Activity + // path, while an explicitly supplied false can still request the + // historical service PendingIntent for live-notification compatibility. + // Historical replays with an official sender route always use the + // SDK-first Activity hand-off because stale vendor bridge tokens must + // never re-enter the legacy Service click path. + Boolean explicitSetting = configuration.keys().contains("use_clicked_activity") + ? configuration.useClickedActivity(false) + : null; + boolean useActivity = shouldUseActivityClick( + explicitSetting, messagingStyle, activityIntent, replaySenderRoute); + if (!useActivity) { + return ClickPendingIntent.service(PendingIntent.getService( + context, notificationId, intent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE)); + } + + // A sender may publish a private proxy Activity (the common pattern for + // MiPush bridge implementations). XMSF cannot start a non-exported + // Activity because Android enforces the target UID at PendingIntent + // send time. Route those clicks through an isolated user-click hand-off; + // it delivers the payload to the target SDK first and opens the target + // launcher only as a bounded fallback. Exported routes use the direct + // Activity PendingIntent so HyperOS can provide its normal conversation + // and floating-window affordances. + boolean targetActivityExported = isActivityExported(context, activityIntent); + if (shouldUseClickTrampoline(replaySenderRoute, targetActivityExported)) { + Intent clickTrampoline = new Intent(context, + com.xiaomi.xmsf.NotificationClickActivity.class); + clickTrampoline.putExtras(extra); + clickTrampoline.putExtra( + com.xiaomi.xmsf.NotificationClickActivity.EXTRA_TARGET_INTENT, + activityIntent); + clickTrampoline.putExtra( + com.xiaomi.xmsf.NotificationClickActivity.EXTRA_SERVICE_INTENT, + intent); + clickTrampoline.putExtra( + com.xiaomi.xmsf.NotificationClickActivity.EXTRA_TARGET_ACTIVITY_PRIVATE, + !targetActivityExported); + clickTrampoline.putExtra( + com.xiaomi.xmsf.NotificationClickActivity.EXTRA_MANUAL_REPLAY, + replaySenderRoute); + clickTrampoline.putExtra( + com.xiaomi.xmsf.NotificationClickActivity.EXTRA_TARGET_PACKAGE, + targetPackage); + clickTrampoline.setData(new Uri.Builder() + .scheme("xmsf-notification") + .authority("click") + .appendPath(targetPackage) + .appendPath(Integer.toString(notificationId)) + .build()); + clickTrampoline.addFlags( + Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS); + return ClickPendingIntent.activity(PendingIntent.getActivity( + context, notificationId, clickTrampoline, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE)); + } + + // Keep the official MiPush click contract for sender-declared routes + // and launcher fallback: launch the validated client Activity directly + // and attach the original service Intent under the standard bridge key. + // Focus/payload-discovered deep links are already complete routes and + // must remain free of unrelated MiPush bridge extras. + if (shouldAttachMiPushBridgeExtras( + clickRoute != null && clickRoute.discoveredRoute)) { + activityIntent.putExtra("mipush_serviceIntent", intent); + activityIntent.putExtras(intent); + } + return ClickPendingIntent.activity(PendingIntent.getActivity( + context, notificationId, activityIntent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE)); + } + + /** API-independent PendingIntent type contract; PendingIntent.isActivity() requires API 31. */ + private static final class ClickPendingIntent { + final PendingIntent pendingIntent; + final boolean activity; + + private ClickPendingIntent(PendingIntent pendingIntent, boolean activity) { + this.pendingIntent = pendingIntent; + this.activity = activity; + } + + static ClickPendingIntent activity(PendingIntent pendingIntent) { + return new ClickPendingIntent(pendingIntent, true); + } + + static ClickPendingIntent service(PendingIntent pendingIntent) { + return new ClickPendingIntent(pendingIntent, false); + } + } + + /** Only a service contentIntent needs HyperOS's auxiliary service whitelist token. */ + static boolean shouldCarryTemporaryWhitelist(boolean activityPendingIntent) { + return !activityPendingIntent; + } + + /** SDK-owned/private routes use the isolated hand-off; exported live routes stay direct. */ + static boolean shouldUseClickTrampoline( + boolean replaySenderRoute, boolean targetActivityExported) { + return replaySenderRoute || !targetActivityExported; + } + + /** + * Sender-declared routes and launcher fallback consume the original MiPush + * bridge payload. Routes inferred from focus parameters or encrypted + * application payloads are already complete deep links and must stay clean, + * matching the target application's own notification PendingIntent. + */ + static boolean shouldAttachMiPushBridgeExtras(boolean discoveredRoute) { + return !discoveredRoute; + } + + /** Only manual replays of an actual sender route use the SDK-first hand-off. */ + static boolean shouldUseReplayClickTrampoline( + boolean replay, boolean routePresent, boolean discoveredRoute) { + return replay && routePresent && !discoveredRoute; + } + + /** + * Preserve the sender's navigation contract when a presentation + * configuration rewrites click metadata. External deep links can open the + * correct detail page but make it the root of a new task; the sender's + * official bridge retains application-specific routing and back-stack + * behavior. A configuration can explicitly opt into its replacement route + * through {@link #ALLOW_CLICK_ROUTE_REWRITE}. + */ + @Nullable + private static ClickRouteResolution resolveRestoredSenderClickRoute( + Context context, XmPushActionContainer configuredContainer, + @Nullable byte[] originalPayload) { + XmPushActionContainer senderContainer = null; + try { + senderContainer = XMPushUtils.packToContainer(originalPayload); + } catch (Throwable error) { + logger.d("Unable to restore sender notification click metadata"); + } + + if (senderContainer != null + && configuredContainer != null + && Objects.equals(publishPackageName(senderContainer), + publishPackageName(configuredContainer)) + && shouldPreferSenderClickContract( + senderContainer.getMetaInfo(), configuredContainer.getMetaInfo())) { + ClickRouteResolution senderRoute = + resolveSdkClickRoute(context, senderContainer); + if (senderRoute != null + && !senderRoute.discoveredRoute + && isActivityExported(context, senderRoute.intent)) { + logger.d("Restoring sender-declared notification click route for " + + publishPackageName(configuredContainer)); + return senderRoute; + } + } + return null; + } + + /** + * Returns whether configuration changed fields that define the notification + * click contract and did not explicitly opt into that rewrite. Styling, + * grouping and focus-rendering metadata are deliberately ignored. + */ + static boolean shouldPreferSenderClickContract( + @Nullable PushMetaInfo senderMeta, + @Nullable PushMetaInfo configuredMeta) { + if (senderMeta == null || configuredMeta == null) { + return false; + } + Map configuredExtra = configuredMeta.getExtra(); + String rewriteOptIn = configuredExtra == null + ? null : configuredExtra.get(ALLOW_CLICK_ROUTE_REWRITE); + if (rewriteOptIn != null + && "true".equalsIgnoreCase(rewriteOptIn.trim())) { + return false; + } + if (!Objects.equals(senderMeta.url, configuredMeta.url)) { + return true; + } + Map senderExtra = senderMeta.getExtra(); + for (String key : CLICK_ROUTE_EXTRA_KEYS) { + String senderValue = senderExtra == null ? null : senderExtra.get(key); + String configuredValue = + configuredExtra == null ? null : configuredExtra.get(key); + if (!Objects.equals(senderValue, configuredValue)) { + return true; + } + } + return false; + } + + private static boolean isActivityExported(Context context, @Nullable Intent activityIntent) { + if (context == null || activityIntent == null) { + return false; + } + try { + ResolveInfo resolved = context.getPackageManager() + .resolveActivity(activityIntent, PackageManager.MATCH_DEFAULT_ONLY); + return resolved != null && resolved.activityInfo != null + && resolved.activityInfo.exported; + } catch (Throwable ignored) { + return false; + } + } + + /** + * HyperOS exposes the conversation mini-window affordance only when the + * notification click is an Activity PendingIntent. Messaging notifications + * already carry a validated target Activity through their SDK intent. All + * notification types use that path by default; the service PendingIntent is + * retained only when configuration explicitly opts out or no Activity can + * be resolved. + */ + static boolean shouldUseActivityClick( + @Nullable Boolean explicitSetting, boolean messagingStyle, + @Nullable Intent activityIntent, boolean replaySenderRoute) { + // A missing/invalid target can never be upgraded to an Activity + // PendingIntent. The caller supplies only intents validated against the + // target package, while this guard keeps the fallback safe for all paths. + if (activityIntent == null) { + return false; + } + // A manual replay has no valid live vendor-click token to fall back to. + // Keep it in the user-initiated SDK hand-off even when an old per-app + // compatibility setting requested the legacy Service PendingIntent. + if (replaySenderRoute) { + return true; + } + // Explicit configuration always wins over the MessagingStyle default, + // including an explicit false. An absent setting now uses the direct + // Activity path for both ordinary and MessagingStyle notifications; + // this is required for reliable Android 16 background-click handling. + return explicitSetting == null || explicitSetting; + } + + /** + * Returns the target package's exported launcher Activity after checking the + * resolved component. This prevents an implicit launcher intent from being + * redirected to another package on unusual ROMs. + */ + @Nullable + private static Intent getLaunchIntent(Context context, String packageName) { + if (context == null || TextUtils.isEmpty(packageName)) { + return null; + } + try { + Intent launchIntent = context.getPackageManager() + .getLaunchIntentForPackage(packageName); + if (launchIntent == null) { + return null; + } + ResolveInfo resolved = context.getPackageManager() + .resolveActivity(launchIntent, PackageManager.MATCH_DEFAULT_ONLY); + if (!isResolvedActivityInTargetPackage(packageName, resolved)) { + return null; + } + launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + return launchIntent; + } catch (Throwable error) { + logger.w("Unable to resolve launcher Activity for " + packageName, error); + return null; } - activityIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - activityIntent.putExtra("mipush_serviceIntent", intent); - activityIntent.putExtras(intent); - return PendingIntent.getActivity(context, notificationId, activityIntent, PendingIntent.FLAG_UPDATE_CURRENT); } /** * @see PushMessageProcessor#getNotificationMessageIntent */ public static Intent getSdkIntent(Context context, XmPushActionContainer container) { - String pkgName = container.packageName; - PushMetaInfo paramPushMetaInfo = container.getMetaInfo(); - Map extra = paramPushMetaInfo.getExtra(); - if (extra == null) { + ClickRouteResolution route = resolveSdkClickRoute(context, container); + return route == null ? null : route.intent; + } + + @Nullable + private static ClickRouteResolution resolveSdkClickRoute( + Context context, XmPushActionContainer container) { + if (context == null || container == null + || TextUtils.isEmpty(publishPackageName(container))) { return null; } - - if (!extra.containsKey(PushConstants.EXTRA_PARAM_NOTIFY_EFFECT)) { + String pkgName = publishPackageName(container); + PushMetaInfo paramPushMetaInfo = container.getMetaInfo(); + if (paramPushMetaInfo == null) { return null; } - + Map extra = paramPushMetaInfo.getExtra(); + String typeId = extra == null ? null : extra.get(PushConstants.EXTRA_PARAM_NOTIFY_EFFECT); Intent intent = null; - - String typeId = extra.get(PushConstants.EXTRA_PARAM_NOTIFY_EFFECT); if (PushConstants.NOTIFICATION_CLICK_DEFAULT.equals(typeId)) { try { intent = context.getPackageManager().getLaunchIntentForPackage(pkgName); @@ -670,39 +1338,342 @@ public static Intent getSdkIntent(Context context, XmPushActionContainer contain } } + // Focus notifications may carry the actual detail page in the public + // miui.focus.param JSON instead of the ordinary MiPush click fields. + // Resolve that route before falling back to the encrypted message + // payload; this is generic and keeps delivery pages usable for every + // sender that follows the focus protocol. + Intent focusIntent = getFocusRouteIntent(context, container); + + // Some SDKs keep the actual deep link in the encrypted SendMessage + // payload rather than in metaInfo.extra. Decode it with the stored + // registration secret and inspect only documented route-like fields; + // this remains app-agnostic and lets clients without click metadata + // work without package-specific adapters. + // + // An explicit notify_effect/intent_uri is the sender's official bridge + // contract. Keep it authoritative: sender proxy Activities normally + // consume the complete MiPush click extras. + // A few clients publish a private proxy, however. XMSF cannot launch + // that Activity under its own UID, so an exported route discovered in + // the encrypted payload is safer and more useful than retaining an + // unusable private component. Keep the official route for exported + // Activities; only private routes may be replaced by an exported + // payload deep link. + Intent payloadIntent = getPayloadRouteIntent(context, container); + // A default-launcher effect is only a generic fallback. If the client + // also supplied a concrete deep link in its encrypted payload, prefer + // that link. Explicit routes remain authoritative while they resolve + // to an exported Activity; private routes are replaced above. + Intent authoritativeSdkRoute = + PushConstants.NOTIFICATION_CLICK_DEFAULT.equals(typeId) ? null : intent; + if (authoritativeSdkRoute != null + && focusIntent != null + && !isActivityExported(context, authoritativeSdkRoute) + && isActivityExported(context, focusIntent)) { + authoritativeSdkRoute = focusIntent; + } + if (authoritativeSdkRoute != null + && payloadIntent != null + && !isActivityExported(context, authoritativeSdkRoute) + && isActivityExported(context, payloadIntent)) { + authoritativeSdkRoute = payloadIntent; + } + intent = chooseClickRoute(authoritativeSdkRoute, focusIntent, payloadIntent); + if (intent != null) { + boolean discoveredRoute = isDiscoveredClickRoute( + intent, focusIntent, payloadIntent); + // Activity PendingIntents use the standard target-task contract. + // addFlags preserves all flags explicitly supplied by the sender. intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - if (context.getPackageManager().resolveActivity(intent, PackageManager.MATCH_DEFAULT_ONLY) != null) { - //TODO fixit - - //we don't have RegSecret we cannot decode push action - - if (inFetchIntentBlackList(pkgName)) { + ResolveInfo resolvedActivity = context.getPackageManager() + .resolveActivity(intent, PackageManager.MATCH_DEFAULT_ONLY); + if (isResolvedActivityInTargetPackage(pkgName, resolvedActivity)) { + // A package-only Intent is still implicit at PendingIntent send + // time. HyperOS/Android 16 may resolve it again under a + // different foreground policy (or reject it while the shade is + // closing), which makes a notification appear to do nothing. + // Freeze the component selected by PackageManager after the + // package ownership check so the user click has a deterministic + // destination. Keep the sender-defined action, data, flags and + // extras because URI routes may rely on all of them. + intent = makeResolvedActivityExplicit(pkgName, intent, resolvedActivity); + if (intent == null) { return null; } - return intent; + return new ClickRouteResolution(intent, discoveredRoute); } } return null; } + private static final class ClickRouteResolution { + final Intent intent; + final boolean discoveredRoute; + + ClickRouteResolution(Intent intent, boolean discoveredRoute) { + this.intent = intent; + this.discoveredRoute = discoveredRoute; + } + } + + private static final int PAYLOAD_ROUTE_MAX_DEPTH = 8; + private static final int PAYLOAD_ROUTE_MAX_NODES = 256; + /** Maximum size of a single URI/intent route extracted from a payload. */ + private static final int PAYLOAD_ROUTE_MAX_LENGTH = 16 * 1024; /** - * tmp black list - * - * @param pkg package name - * @return is in black list + * Do not truncate JSON before parsing it. A truncated document is invalid + * and silently forces a launcher fallback. Reject truly unreasonable + * documents instead. */ - private static boolean inFetchIntentBlackList(String pkg) { - if (pkg.contains("youku")) { - return true; + private static final int PAYLOAD_DOCUMENT_MAX_LENGTH = 64 * 1024; + + /** + * Selects the sender-provided click bridge before a route discovered in the + * encrypted application payload. The latter is only a fallback for clients + * that did not publish a notify_effect route at all. + */ + @Nullable + static Intent chooseClickRoute( + @Nullable Intent explicitSdkRoute, @Nullable Intent payloadRoute) { + return chooseClickRoute(explicitSdkRoute, null, payloadRoute); + } + + /** + * Select the sender's explicit route, then a public focus-protocol route, + * then a route discovered in the encrypted application payload. + */ + @Nullable + static Intent chooseClickRoute( + @Nullable Intent explicitSdkRoute, + @Nullable Intent focusRoute, + @Nullable Intent payloadRoute) { + if (explicitSdkRoute != null) { + return explicitSdkRoute; } + return focusRoute != null ? focusRoute : payloadRoute; + } - return false; + /** + * Route selection intentionally preserves object identity so the caller can + * distinguish an SDK-declared bridge from a deep link inferred by XMSF. + */ + static boolean isDiscoveredClickRoute( + @Nullable Intent selectedRoute, + @Nullable Intent focusRoute, + @Nullable Intent payloadRoute) { + return selectedRoute != null + && (selectedRoute == focusRoute || selectedRoute == payloadRoute); + } + + @Nullable + private static Intent getFocusRouteIntent( + Context context, XmPushActionContainer container) { + if (context == null || container == null + || TextUtils.isEmpty(publishPackageName(container))) { + return null; + } + try { + CustomConfiguration configuration = + XMPushUtils.getConfiguration(container.getMetaInfo()); + String[] parameters = { + configuration.focusParam(null), + configuration.focusParamCustom(null) + }; + for (String parameter : parameters) { + if (!FocusNotificationSafety.isWellFormedParameter(parameter)) { + continue; + } + Intent route = findPayloadRoute( + context, publishPackageName(container), new JSONObject(parameter), 0, + new int[]{0}); + if (route != null) { + return route; + } + } + } catch (Throwable error) { + // Focus routing is optional. A malformed or unsupported focus + // payload must retain the normal MiPush click fallback. + logger.d("Unable to decode a focus notification click route"); + } + return null; + } + + @Nullable + private static Intent getPayloadRouteIntent(Context context, XmPushActionContainer container) { + try { + String regSec = RegSecUtils.getRegSec(container); + if (TextUtils.isEmpty(regSec)) { + return null; + } + TBase messageBody = ConvertUtils.getResponseMessageBodyFromContainer(container, regSec); + if (!(messageBody instanceof XmPushActionSendMessage)) { + return null; + } + PushMessage message = ((XmPushActionSendMessage) messageBody).getMessage(); + if (message == null || TextUtils.isEmpty(message.getPayload())) { + return null; + } + String payload = message.getPayload().trim(); + if (payload.length() > PAYLOAD_DOCUMENT_MAX_LENGTH) { + return null; + } + int[] nodeCount = new int[]{0}; + if (payload.startsWith("{")) { + return findPayloadRoute(context, publishPackageName(container), + new JSONObject(payload), 0, nodeCount); + } + if (payload.startsWith("[")) { + return findPayloadRoute(context, publishPackageName(container), + new JSONArray(payload), 0, nodeCount); + } + return resolvePayloadRoute(context, publishPackageName(container), payload); + } catch (Throwable error) { + // Missing registration secrets, malformed app payloads, and old + // protocol variants must fall back to notify_effect/Launcher. + logger.d("Unable to decode a notification payload route for " + + publishPackageName(container)); + return null; + } + } + + @Nullable + private static Intent findPayloadRoute( + Context context, String packageName, Object value, int depth, int[] nodeCount) { + if (value == null || depth > PAYLOAD_ROUTE_MAX_DEPTH + || nodeCount[0]++ >= PAYLOAD_ROUTE_MAX_NODES) { + return null; + } + if (value instanceof JSONObject) { + JSONObject object = (JSONObject) value; + java.util.Iterator keys = object.keys(); + while (keys.hasNext()) { + String key = keys.next(); + Object child = object.opt(key); + if (child instanceof String && isPayloadRouteKey(key)) { + Intent candidate = resolvePayloadRoute( + context, packageName, (String) child); + if (candidate != null) { + return candidate; + } + } + Intent nested = findPayloadRoute( + context, packageName, child, depth + 1, nodeCount); + if (nested != null) { + return nested; + } + } + } else if (value instanceof JSONArray) { + JSONArray array = (JSONArray) value; + for (int i = 0; i < array.length(); i++) { + Intent nested = findPayloadRoute( + context, packageName, array.opt(i), depth + 1, nodeCount); + if (nested != null) { + return nested; + } + } + } else if (value instanceof String) { + String candidate = ((String) value).trim(); + if (candidate.startsWith("{") || candidate.startsWith("[")) { + try { + Object nested = candidate.startsWith("{") + ? new JSONObject(candidate) : new JSONArray(candidate); + return findPayloadRoute( + context, packageName, nested, depth + 1, nodeCount); + } catch (Throwable ignored) { + // Not nested JSON; continue with the safe no-route result. + } + } + } + return null; + } + + private static boolean isPayloadRouteKey(String key) { + if (TextUtils.isEmpty(key)) { + return false; + } + String normalized = key.toLowerCase(Locale.ROOT); + return normalized.equals("url") + || normalized.equals("uri") + || normalized.equals("scheme") + || normalized.equals("jump_scheme") + || normalized.equals("intent_uri") + || normalized.equals("deep_link") + || normalized.equals("deeplink") + || normalized.equals("link") + || normalized.endsWith("_url") + || normalized.endsWith("_uri"); + } + + @Nullable + private static Intent resolvePayloadRoute(Context context, String packageName, String value) { + if (context == null || TextUtils.isEmpty(packageName) || TextUtils.isEmpty(value)) { + return null; + } + String route = value.trim(); + if (route.length() == 0 || route.length() > PAYLOAD_ROUTE_MAX_LENGTH + || route.indexOf('\n') >= 0 || route.indexOf('\r') >= 0) { + return null; + } + try { + Intent intent; + if (route.startsWith("intent:")) { + intent = Intent.parseUri(route, Intent.URI_INTENT_SCHEME); + } else { + Uri uri = Uri.parse(route); + if (TextUtils.isEmpty(uri.getScheme())) { + return null; + } + intent = new Intent(Intent.ACTION_VIEW, uri); + } + intent.setPackage(packageName); + ResolveInfo resolved = context.getPackageManager() + .resolveActivity(intent, PackageManager.MATCH_DEFAULT_ONLY); + if (!isResolvedActivityInTargetPackage(packageName, resolved)) { + return null; + } + return makeResolvedActivityExplicit(packageName, intent, resolved); + } catch (Throwable ignored) { + return null; + } + } + + /** + * Converts a package-only click intent into the exact Activity selected by + * PackageManager. Keeping this small and side-effect free makes the + * security boundary easy to exercise in unit tests. + */ + @Nullable + static Intent makeResolvedActivityExplicit( + String targetPackage, @Nullable Intent intent, @Nullable ResolveInfo resolvedActivity) { + if (intent == null + || !isResolvedActivityInTargetPackage(targetPackage, resolvedActivity) + || resolvedActivity.activityInfo.name == null + || resolvedActivity.activityInfo.name.length() == 0) { + return null; + } + intent.setComponent(new ComponentName( + resolvedActivity.activityInfo.packageName, + resolvedActivity.activityInfo.name)); + return intent; } + /** + * Ensures a click Activity resolved from push metadata cannot escape the + * package that owns the notification. A null/empty target or incomplete + * resolution is rejected using the safe service-pending-intent fallback. + */ + static boolean isResolvedActivityInTargetPackage(String targetPackage, ResolveInfo resolveInfo) { + return targetPackage != null + && !targetPackage.isEmpty() + && resolveInfo != null + && resolveInfo.activityInfo != null + && targetPackage.equals(resolveInfo.activityInfo.packageName); + } private static PendingIntent startServicePendingIntent(Context paramContext, XmPushActionContainer paramXmPushActionContainer, PushMetaInfo paramPushMetaInfo, byte[] paramArrayOfByte) { if (paramPushMetaInfo == null) { @@ -714,16 +1685,19 @@ private static PendingIntent startServicePendingIntent(Context paramContext, XmP localIntent = new Intent(); localIntent.setComponent(new ComponentName("com.xiaomi.xmsf", "com.xiaomi.mipush.sdk.PushMessageHandler")); } else { + String targetPackage = publishPackageName(paramXmPushActionContainer); localIntent = new Intent(PushConstants.MIPUSH_ACTION_NEW_MESSAGE); - localIntent.setComponent(new ComponentName(paramXmPushActionContainer.packageName, "com.xiaomi.mipush.sdk.PushMessageHandler")); + localIntent.setComponent(new ComponentName(targetPackage, "com.xiaomi.mipush.sdk.PushMessageHandler")); } localIntent.putExtra(PushConstants.MIPUSH_EXTRA_PAYLOAD, paramArrayOfByte); localIntent.putExtra(FROM_NOTIFICATION, true); localIntent.addCategory(String.valueOf(paramPushMetaInfo.getNotifyId())); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - return PendingIntent.getForegroundService(paramContext, 0, localIntent, PendingIntent.FLAG_UPDATE_CURRENT); + return PendingIntent.getForegroundService(paramContext, 0, localIntent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); } else { - return PendingIntent.getService(paramContext, 0, localIntent, PendingIntent.FLAG_UPDATE_CURRENT); + return PendingIntent.getService(paramContext, 0, localIntent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); } } @@ -755,6 +1729,16 @@ private static NotificationCompat.Builder setNotificationStyleAction(Notificatio if (stylePendingIntent3 != null && !TextUtils.isEmpty(metaExtra.get(NOTIFICATION_STYLE_BUTTON_RIGHT_NAME))) { builder.addAction(0, metaExtra.get(NOTIFICATION_STYLE_BUTTON_RIGHT_NAME), stylePendingIntent3); } + if ("3".equals(metaExtra.get(NOTIFICATION_STYLE_TYPE))) { + PendingIntent colorfulPendingIntent = getStylePendingIntent( + context, pkgName, NOTIFICATION_ACTION_BUTTON_PLACE_COLORFUL, metaExtra); + String colorfulButtonText = metaExtra.get(NOTIFICATION_COLORFUL_BUTTON_TEXT); + if (colorfulPendingIntent != null && !TextUtils.isEmpty(colorfulButtonText)) { + // Preserve the official Colorful button as a standard action + // when MIUI's private RemoteViews implementation is unavailable. + builder.addAction(0, colorfulButtonText, colorfulPendingIntent); + } + } return builder; } @@ -763,20 +1747,13 @@ private static PendingIntent getStylePendingIntent(Context context, String pkgNa if (metaExtra == null || (intent = getPendingIntentFromExtra(context, pkgName, place, metaExtra)) == null) { return null; } - return PendingIntent.getActivity(context, 0, intent, 0); + return PendingIntent.getActivity(context, place, intent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); } private static Intent getPendingIntentFromExtra(Context context, String pkgName, int place, Map extra) { - String str; - String webUriKey; - String intentUriKey; - String intentClassKey; - if (place < NOTIFICATION_ACTION_BUTTON_PLACE_MID) { - str = NOTIFICATION_STYLE_BUTTON_LEFT_NOTIFY_EFFECT; - } else { - str = place < NOTIFICATION_ACTION_BUTTON_PLACE_RIGHT ? NOTIFICATION_STYLE_BUTTON_MID_NOTIFY_EFFECT : NOTIFICATION_STYLE_BUTTON_RIGHT_NOTIFY_EFFECT; - } - String typeId = extra.get(str); + StyleActionKeys keys = styleActionKeys(place); + String typeId = extra.get(keys.notifyEffect); if (TextUtils.isEmpty(typeId)) { return null; } @@ -788,18 +1765,8 @@ private static Intent getPendingIntentFromExtra(Context context, String pkgName, logger.e("Cause: " + e.getMessage()); } } else if (PushConstants.NOTIFICATION_CLICK_INTENT.equals(typeId)) { - if (place < NOTIFICATION_ACTION_BUTTON_PLACE_MID) { - intentUriKey = NOTIFICATION_STYLE_BUTTON_LEFT_INTENT_URI; - } else { - intentUriKey = place < NOTIFICATION_ACTION_BUTTON_PLACE_RIGHT ? NOTIFICATION_STYLE_BUTTON_MID_INTENT_URI : NOTIFICATION_STYLE_BUTTON_RIGHT_INTENT_URI; - } - if (place < NOTIFICATION_ACTION_BUTTON_PLACE_MID) { - intentClassKey = NOTIFICATION_STYLE_BUTTON_LEFT_INTENT_CLASS; - } else { - intentClassKey = place < NOTIFICATION_ACTION_BUTTON_PLACE_RIGHT ? NOTIFICATION_STYLE_BUTTON_MID_INTENT_CLASS : NOTIFICATION_STYLE_BUTTON_RIGHT_INTENT_CLASS; - } - if (extra.containsKey(intentUriKey)) { - String intentStr = extra.get(intentUriKey); + if (extra.containsKey(keys.intentUri)) { + String intentStr = extra.get(keys.intentUri); if (intentStr != null) { try { intent = Intent.parseUri(intentStr, Intent.URI_INTENT_SCHEME); @@ -808,18 +1775,13 @@ private static Intent getPendingIntentFromExtra(Context context, String pkgName, logger.e("Cause: " + e2.getMessage()); } } - } else if (extra.containsKey(intentClassKey)) { - String className = extra.get(intentClassKey); + } else if (extra.containsKey(keys.intentClass)) { + String className = extra.get(keys.intentClass); intent = new Intent(); intent.setComponent(new ComponentName(pkgName, className)); } } else if (PushConstants.NOTIFICATION_CLICK_WEB_PAGE.equals(typeId)) { - if (place < NOTIFICATION_ACTION_BUTTON_PLACE_MID) { - webUriKey = NOTIFICATION_STYLE_BUTTON_LEFT_WEB_URI; - } else { - webUriKey = place < NOTIFICATION_ACTION_BUTTON_PLACE_RIGHT ? NOTIFICATION_STYLE_BUTTON_MID_WEB_URI : NOTIFICATION_STYLE_BUTTON_RIGHT_WEB_URI; - } - String uri = extra.get(webUriKey); + String uri = extra.get(keys.webUri); if (!TextUtils.isEmpty(uri)) { String tmp = uri.trim(); if (!tmp.startsWith("http://") && !tmp.startsWith("https://")) { @@ -851,5 +1813,33 @@ private static Intent getPendingIntentFromExtra(Context context, String pkgName, return null; } + static StyleActionKeys styleActionKeys(int place) { + if (place == NOTIFICATION_ACTION_BUTTON_PLACE_COLORFUL) { + return COLORFUL_ACTION_KEYS; + } + if (place < NOTIFICATION_ACTION_BUTTON_PLACE_MID) { + return LEFT_ACTION_KEYS; + } + if (place < NOTIFICATION_ACTION_BUTTON_PLACE_RIGHT) { + return MID_ACTION_KEYS; + } + return RIGHT_ACTION_KEYS; + } + + static final class StyleActionKeys { + final String notifyEffect; + final String intentUri; + final String intentClass; + final String webUri; + + StyleActionKeys(String notifyEffect, String intentUri, + String intentClass, String webUri) { + this.notifyEffect = notifyEffect; + this.intentUri = intentUri; + this.intentClass = intentClass; + this.webUri = webUri; + } + } + } diff --git a/push/src/main/java/com/xiaomi/push/service/MyNotificationIconHelper.java b/push/src/main/java/com/xiaomi/push/service/MyNotificationIconHelper.java index 406106060..dc905bab0 100644 --- a/push/src/main/java/com/xiaomi/push/service/MyNotificationIconHelper.java +++ b/push/src/main/java/com/xiaomi/push/service/MyNotificationIconHelper.java @@ -22,9 +22,13 @@ public class MyNotificationIconHelper { private static final int CONNECT_TIMEOUT = 8000; private static final int READ_TIMEOUT = 20000; + private static final int FOCUS_CONNECT_TIMEOUT = 2000; + private static final int FOCUS_READ_TIMEOUT = 3000; private static final int READ_UNIT = 1024; private static final int STANDARD_DENSITY = 160; private static final int STANDARD_ICON_SIZE = 48; + private static final int MAX_DECODED_DIMENSION = 2048; + private static final long MAX_DECODED_PIXELS = 1024L * 1024L; /* loaded from: classes.dex */ public static class GetIconResult { @@ -38,36 +42,60 @@ public GetIconResult(Bitmap bitmap, long downloadSize) { } public static GetIconResult getIconFromUrl(Context context, String urlStr, int maxDownloadBytes) { + return getIconFromUrl(context, urlStr, maxDownloadBytes, CONNECT_TIMEOUT, READ_TIMEOUT); + } + + public static GetIconResult getFocusIconFromUrl(Context context, String urlStr, int maxDownloadBytes) { + return getIconFromUrl(context, urlStr, maxDownloadBytes, FOCUS_CONNECT_TIMEOUT, FOCUS_READ_TIMEOUT); + } + + /** Resolve the content/resource URI form used by HyperOS focus notifications. */ + public static GetIconResult getFocusIconFromUri(Context context, String uriStr, int maxDownloadBytes) { + InputStream inputStream = null; + GetIconResult result = new GetIconResult(null, 0L); + try { + inputStream = context.getContentResolver().openInputStream(Uri.parse(uriStr)); + byte[] data = readAtMost(inputStream, maxDownloadBytes); + if (data == null) { + result.downloadSize = maxDownloadBytes + 1L; + return result; + } + result.downloadSize = data.length; + if (data.length > 0) { + int sampleSize = getSampleSize(context, new ByteArrayInputStream(data)); + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inSampleSize = sampleSize; + result.bitmap = BitmapFactory.decodeByteArray(data, 0, data.length, options); + } + } catch (Throwable e) { + MyLog.e(e); + } finally { + IOUtils.closeQuietly(inputStream); + } + return result; + } + + public static GetIconResult getIconFromUrl(Context context, String urlStr, int maxDownloadBytes, int connectTimeout, int readTimeout) { InputStream isForBitmapSize = null; GetIconResult result = new GetIconResult(null, 0L); try { - GetDataResult getDataResult = getDataFromUrl(urlStr, maxDownloadBytes); + GetDataResult getDataResult = getDataFromUrl(urlStr, maxDownloadBytes, connectTimeout, readTimeout); if (getDataResult != null) { result.downloadSize = getDataResult.downloadSize; byte[] data = getDataResult.data; - if (data != null) { - InputStream isForBitmapSize2 = new ByteArrayInputStream(data); - try { - int sampleSize = getSampleSize(context, isForBitmapSize2); - BitmapFactory.Options options = new BitmapFactory.Options(); - options.inSampleSize = sampleSize; - result.bitmap = BitmapFactory.decodeByteArray(data, 0, data.length, options); - isForBitmapSize = isForBitmapSize2; - } catch (Exception e) { - isForBitmapSize = isForBitmapSize2; - MyLog.e(e); - IOUtils.closeQuietly(isForBitmapSize); - return result; - } catch (Throwable th) { - isForBitmapSize = isForBitmapSize2; - IOUtils.closeQuietly(isForBitmapSize); - throw th; - } + if (data != null && data.length > 0) { + isForBitmapSize = new ByteArrayInputStream(data); + int sampleSize = getSampleSize(context, isForBitmapSize); + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inSampleSize = sampleSize; + result.bitmap = BitmapFactory.decodeByteArray(data, 0, data.length, options); } } - } catch (Throwable ignored) { + } catch (Throwable e) { + MyLog.e(e); + } finally { + IOUtils.closeQuietly(isForBitmapSize); } - IOUtils.closeQuietly(isForBitmapSize); return result; } @@ -82,78 +110,78 @@ public GetDataResult(byte[] data, int downloadSize) { } } - private static GetDataResult getDataFromUrl(String urlStr, int maxDownloadBytes) { - GetDataResult getDataResult; + private static GetDataResult getDataFromUrl(String urlStr, int maxDownloadBytes, int connectTimeout, int readTimeout) { HttpURLConnection conn = null; + InputStream inputStream = null; try { - try { - URL url = new URL(urlStr); - HttpURLConnection conn2 = (HttpURLConnection) url.openConnection(); - conn2.setConnectTimeout(CONNECT_TIMEOUT); - conn2.setReadTimeout(READ_TIMEOUT); - conn2.connect(); - int contentLen = conn2.getContentLength(); - if (contentLen > maxDownloadBytes) { - MyLog.w("Bitmap size is too big, max size is " + maxDownloadBytes + " contentLen size is " + contentLen + " from url " + urlStr); - IOUtils.closeQuietly((InputStream) null); - if (conn2 != null) { - conn2.disconnect(); - } - return null; - } - int responseCode = conn2.getResponseCode(); - if (responseCode != 200) { - MyLog.w("Invalid Http Response Code " + responseCode + " received"); - IOUtils.closeQuietly((InputStream) null); - if (conn2 != null) { - conn2.disconnect(); - } - return null; - } - InputStream inputStream = conn2.getInputStream(); - ByteArrayOutputStream tempOutStream = new ByteArrayOutputStream(); - int availableSpace = maxDownloadBytes; - byte[] dataUnit = new byte[READ_UNIT]; - while (availableSpace > 0) { - int readCount = inputStream.read(dataUnit, 0, READ_UNIT); - if (readCount == -1) { - break; - } - availableSpace -= readCount; - tempOutStream.write(dataUnit, 0, readCount); - } - if (availableSpace <= 0) { - MyLog.w("length " + maxDownloadBytes + " exhausted."); - getDataResult = new GetDataResult(null, maxDownloadBytes); - IOUtils.closeQuietly(inputStream); - if (conn2 == null) { - return getDataResult; - } - } else { - byte[] data = tempOutStream.toByteArray(); - getDataResult = new GetDataResult(data, data.length); - IOUtils.closeQuietly(inputStream); - if (conn2 == null) { - return getDataResult; - } + URL url = new URL(urlStr); + conn = (HttpURLConnection) url.openConnection(); + conn.setConnectTimeout(connectTimeout); + conn.setReadTimeout(readTimeout); + conn.connect(); + int contentLen = conn.getContentLength(); + if (contentLen > maxDownloadBytes) { + MyLog.w("Bitmap size is too big, max size is " + maxDownloadBytes + " contentLen size is " + contentLen + " from url " + urlStr); + return null; + } + int responseCode = conn.getResponseCode(); + if (responseCode != 200) { + MyLog.w("Invalid Http Response Code " + responseCode + " received"); + return null; + } + inputStream = conn.getInputStream(); + ByteArrayOutputStream tempOutStream = new ByteArrayOutputStream(); + int availableSpace = maxDownloadBytes; + byte[] dataUnit = new byte[READ_UNIT]; + while (availableSpace > 0) { + int readCount = inputStream.read(dataUnit, 0, Math.min(READ_UNIT, availableSpace)); + if (readCount == -1) { + break; } - conn2.disconnect(); - return getDataResult; - } catch (IOException e) { - MyLog.e(e); - IOUtils.closeQuietly((InputStream) null); - if (0 != 0) { + availableSpace -= readCount; + tempOutStream.write(dataUnit, 0, readCount); + } + if (availableSpace <= 0 && inputStream.read() != -1) { + MyLog.w("length " + maxDownloadBytes + " exhausted."); + return new GetDataResult(null, maxDownloadBytes); + } + byte[] data = tempOutStream.toByteArray(); + return new GetDataResult(data, data.length); + } catch (Throwable e) { + MyLog.e(e); + return null; + } finally { + IOUtils.closeQuietly(inputStream); + if (conn != null) { + try { conn.disconnect(); + } catch (Throwable ignored) { } - return null; } - } catch (Throwable th) { - IOUtils.closeQuietly((InputStream) null); - if (0 != 0) { - conn.disconnect(); + } + } + + /** Read one extra byte so callers can distinguish an exact limit from overflow. */ + private static byte[] readAtMost(InputStream inputStream, int maxDownloadBytes) throws IOException { + if (inputStream == null || maxDownloadBytes < 0) { + return null; + } + ByteArrayOutputStream output = new ByteArrayOutputStream(Math.min(maxDownloadBytes, 8192)); + byte[] buffer = new byte[READ_UNIT]; + int total = 0; + while (total <= maxDownloadBytes) { + int read = inputStream.read(buffer, 0, + Math.min(buffer.length, maxDownloadBytes + 1 - total)); + if (read < 0) { + return output.toByteArray(); + } + output.write(buffer, 0, read); + total += read; + if (total > maxDownloadBytes) { + return null; } - throw th; } + return null; } public static Bitmap getIconFromUri(Context context, String uriStr) { @@ -162,28 +190,26 @@ public static Bitmap getIconFromUri(Context context, String uriStr) { InputStream is = null; InputStream isForBitmapSize = null; try { - try { - isForBitmapSize = context.getContentResolver().openInputStream(uri); - int sampleSize = getSampleSize(context, isForBitmapSize); - is = context.getContentResolver().openInputStream(uri); - BitmapFactory.Options options = new BitmapFactory.Options(); - options.inSampleSize = sampleSize; - bitmap = BitmapFactory.decodeStream(is, null, options); - IOUtils.closeQuietly(is); - } catch (IOException e) { - MyLog.e(e); - IOUtils.closeQuietly(is); - } - IOUtils.closeQuietly(isForBitmapSize); + isForBitmapSize = context.getContentResolver().openInputStream(uri); + int sampleSize = getSampleSize(context, isForBitmapSize); + is = context.getContentResolver().openInputStream(uri); + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inSampleSize = sampleSize; + bitmap = BitmapFactory.decodeStream(is, null, options); return bitmap; } catch (Throwable th) { + MyLog.e(th); + return null; + } finally { IOUtils.closeQuietly(is); IOUtils.closeQuietly(isForBitmapSize); - throw th; } } private static int getSampleSize(Context context, InputStream inputStream) { + if (inputStream == null) { + return 1; + } BitmapFactory.Options opt = new BitmapFactory.Options(); opt.inJustDecodeBounds = true; BitmapFactory.decodeStream(inputStream, null, opt); @@ -192,10 +218,42 @@ private static int getSampleSize(Context context, InputStream inputStream) { return 1; } int screenDensity = context.getResources().getDisplayMetrics().densityDpi; - int targetWidth = Math.round((screenDensity / 160.0f) * 48.0f); - if (opt.outWidth <= targetWidth || opt.outHeight <= targetWidth) { + int targetWidth = Math.max(1, + Math.round((screenDensity / (float) STANDARD_DENSITY) * STANDARD_ICON_SIZE)); + return calculateSampleSize(opt.outWidth, opt.outHeight, targetWidth); + } + + /** + * Preserve Xiaomi's 48dp target while bounding pathological panoramic or + * highly-compressed images before BitmapFactory allocates their pixels. + */ + static int calculateSampleSize(int width, int height, int targetWidth) { + if (width <= 0 || height <= 0 || targetWidth <= 0) { return 1; } - return Math.min(opt.outWidth / targetWidth, opt.outHeight / targetWidth); + int sampleSize = 1; + if (width > targetWidth && height > targetWidth) { + int requested = Math.max(1, + Math.min(width / targetWidth, height / targetWidth)); + // BitmapFactory rounds non-power-of-two values down on supported + // Android releases, so model the effective value explicitly. + sampleSize = Integer.highestOneBit(requested); + } + while (decodedDimension(width, sampleSize) > MAX_DECODED_DIMENSION + || decodedDimension(height, sampleSize) > MAX_DECODED_DIMENSION + || decodedPixels(width, height, sampleSize) > MAX_DECODED_PIXELS) { + if (sampleSize > Integer.MAX_VALUE / 2) return Integer.MAX_VALUE; + sampleSize *= 2; + } + return sampleSize; + } + + private static long decodedPixels(int width, int height, int sampleSize) { + return (long) decodedDimension(width, sampleSize) + * decodedDimension(height, sampleSize); + } + + private static int decodedDimension(int value, int sampleSize) { + return (int) (((long) value + sampleSize - 1L) / sampleSize); } -} \ No newline at end of file +} diff --git a/push/src/main/java/com/xiaomi/push/service/NotificationDispatchPipeline.java b/push/src/main/java/com/xiaomi/push/service/NotificationDispatchPipeline.java new file mode 100644 index 000000000..e3d886f4a --- /dev/null +++ b/push/src/main/java/com/xiaomi/push/service/NotificationDispatchPipeline.java @@ -0,0 +1,100 @@ +package com.xiaomi.push.service; + +import com.xiaomi.xmsf.push.utils.PackageConfig; + +import java.util.Set; + +/** + * Keeps the independent notification dispatch phases isolated from one another. + * + *

This class deliberately has no Android dependencies. Configuration + * evaluation happens before the pipeline is entered; a failed evaluation is + * represented by {@code null} and produces the safe default of delivering the + * notification. A failure in wake, notification submission, or open is + * reported to the caller and cannot prevent a later phase from running.

+ */ +final class NotificationDispatchPipeline { + static final String STAGE_WAKE = "wake"; + static final String STAGE_NOTIFY = "notify"; + static final String STAGE_OPEN = "open"; + + private NotificationDispatchPipeline() { + } + + static DispatchPlan planFromOperations(Set operations) { + if (operations == null) { + // Configuration evaluation failed. Do not silently lose the + // standard/focus notification; skip optional side effects. + return DispatchPlan.notifyOnly(); + } + try { + return new DispatchPlan( + operations.contains(PackageConfig.OPERATION_WAKE), + !operations.contains(PackageConfig.OPERATION_IGNORE), + operations.contains(PackageConfig.OPERATION_OPEN)); + } catch (RuntimeException ignored) { + // A malformed/custom Set must not turn a push into a lost + // notification. The conservative fallback is still notify-only. + return DispatchPlan.notifyOnly(); + } + } + + static void dispatch( + DispatchPlan plan, + Stage wake, + Stage notify, + Stage open, + FailureHandler failureHandler) { + DispatchPlan effectivePlan = plan == null ? DispatchPlan.notifyOnly() : plan; + runSafely(effectivePlan.wake, STAGE_WAKE, wake, failureHandler); + runSafely(effectivePlan.notify, STAGE_NOTIFY, notify, failureHandler); + runSafely(effectivePlan.open, STAGE_OPEN, open, failureHandler); + } + + private static void runSafely( + boolean enabled, + String stage, + Stage action, + FailureHandler failureHandler) { + if (!enabled || action == null) { + return; + } + try { + action.run(); + } catch (Exception e) { + // Failure reporting is best-effort too: a logger must not break + // isolation and prevent the next dispatch phase. + try { + if (failureHandler != null) { + failureHandler.onFailure(stage, e); + } + } catch (Exception ignored) { + // Intentionally ignored. + } + } + } + + interface Stage { + void run() throws Exception; + } + + interface FailureHandler { + void onFailure(String stage, Exception exception); + } + + static final class DispatchPlan { + final boolean wake; + final boolean notify; + final boolean open; + + DispatchPlan(boolean wake, boolean notify, boolean open) { + this.wake = wake; + this.notify = notify; + this.open = open; + } + + static DispatchPlan notifyOnly() { + return new DispatchPlan(false, true, false); + } + } +} diff --git a/push/src/main/java/com/xiaomi/push/service/WakeScreenThrottle.java b/push/src/main/java/com/xiaomi/push/service/WakeScreenThrottle.java new file mode 100644 index 000000000..d1df1ad68 --- /dev/null +++ b/push/src/main/java/com/xiaomi/push/service/WakeScreenThrottle.java @@ -0,0 +1,90 @@ +package com.xiaomi.push.service; + +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.function.LongSupplier; + +/** + * Small, process-local gate for explicit screen-wake requests. + * + *

The gate is intentionally independent of Android APIs so that its + * timing and eviction behaviour can be tested on the JVM. Callers should + * only consult it immediately before acquiring a wake lock; it does not + * gate notification publication or any other dispatch stage.

+ */ +final class WakeScreenThrottle { + static final long DEFAULT_MIN_INTERVAL_MILLIS = 5_000L; + static final int DEFAULT_MAX_ENTRIES = 128; + + private final long minimumIntervalMillis; + private final int maxEntries; + private final LongSupplier clock; + private final LinkedHashMap lastWakeByPackage = + new LinkedHashMap<>(16, 0.75f, true); + + WakeScreenThrottle(LongSupplier clock) { + this(clock, DEFAULT_MIN_INTERVAL_MILLIS, DEFAULT_MAX_ENTRIES); + } + + WakeScreenThrottle(LongSupplier clock, long minimumIntervalMillis, int maxEntries) { + if (clock == null) { + throw new IllegalArgumentException("clock must not be null"); + } + if (minimumIntervalMillis < 0L) { + throw new IllegalArgumentException("minimumIntervalMillis must be non-negative"); + } + if (maxEntries <= 0) { + throw new IllegalArgumentException("maxEntries must be positive"); + } + this.clock = clock; + this.minimumIntervalMillis = minimumIntervalMillis; + this.maxEntries = maxEntries; + } + + /** + * Claims permission for a wake request from {@code packageName}. + * + *

The first request for a package is accepted. A clock rollback is + * treated as a new elapsed-time epoch and is accepted as well, preventing + * a wall/elapsed clock anomaly from suppressing wakes indefinitely.

+ */ + synchronized boolean tryAcquire(String packageName) { + return tryAcquireAtLocked(packageName, clock.getAsLong()); + } + + /** Package-private deterministic entry point used by JVM tests. */ + synchronized boolean tryAcquireAt(String packageName, long nowElapsedRealtime) { + return tryAcquireAtLocked(packageName, nowElapsedRealtime); + } + + /** Visible to package tests for verifying the bounded-cache contract. */ + synchronized int entryCountForTest() { + return lastWakeByPackage.size(); + } + + private boolean tryAcquireAtLocked(String packageName, long nowElapsedRealtime) { + Long previous = lastWakeByPackage.get(packageName); + if (previous != null && nowElapsedRealtime >= previous) { + long elapsed = nowElapsedRealtime - previous; + if (elapsed >= 0L && elapsed < minimumIntervalMillis) { + return false; + } + } + + // A backward jump (now < previous), or an elapsed counter overflow, + // deliberately reaches this branch and starts a new clock epoch. + lastWakeByPackage.put(packageName, nowElapsedRealtime); + trimToBound(); + return true; + } + + private void trimToBound() { + if (lastWakeByPackage.size() <= maxEntries) { + return; + } + Iterator> iterator = lastWakeByPackage.entrySet().iterator(); + iterator.next(); + iterator.remove(); + } +} diff --git a/push/src/main/java/com/xiaomi/xms/auth/AuthService.java b/push/src/main/java/com/xiaomi/xms/auth/AuthService.java new file mode 100644 index 000000000..f35468af1 --- /dev/null +++ b/push/src/main/java/com/xiaomi/xms/auth/AuthService.java @@ -0,0 +1,149 @@ +package com.xiaomi.xms.auth; + +import android.app.Service; +import android.content.Intent; +import android.os.Binder; +import android.os.Bundle; +import android.os.IBinder; +import android.os.Parcel; +import android.os.RemoteException; +import android.util.Log; + +import androidx.annotation.Nullable; + +/** + * Small compatibility implementation of the Xiaomi XMS auth endpoint. + * + *

HyperOS SystemUI binds this endpoint before it renders a focus + * notification. The auth split shipped by recent Xiaomi builds is not part + * of our single-APK distribution, so the bind otherwise succeeds with no + * usable result and the renderer receives an empty {@code authResult}.

+ * + *

The wire format intentionally mirrors the generated AIDL interface in + * the stock framework. Keeping it here avoids a dependency on proprietary + * auth classes while remaining harmless on AOSP devices (the service is only + * selected by the Xiaomi SystemUI plugin).

+ */ +public final class AuthService extends Service { + private static final String TAG = "MiPushAuthService"; + private static final String SERVICE_ACTION = "com.xiaomi.xms.auth.BIND_AUTH_SERVICE"; + private static final String DESCRIPTOR = "com.xiaomi.xms.auth.IAuthService"; + private static final String CALLBACK_DESCRIPTOR = + "com.xiaomi.xms.auth.IAuthServiceCallback"; + private static final int TRANSACTION_AUTH = 1; + private static final int TRANSACTION_SYNC_AUTH = 2; + private static final int INTERFACE_TRANSACTION = 0x5f4e5446; + + private final IBinder binder = new AuthBinder(); + + @Override + public void onCreate() { + super.onCreate(); + Log.i(TAG, "Xiaomi auth compatibility service created"); + } + + @Nullable + @Override + public IBinder onBind(Intent intent) { + if (intent == null || !SERVICE_ACTION.equals(intent.getAction())) { + // The framework resolves the service by this action. Returning + // the binder for an implicit/empty bind is still safe and keeps + // compatibility with older plugin revisions which omit action. + Log.w(TAG, "bind without the Xiaomi auth action: " + intent); + } + return binder; + } + + private final class AuthBinder extends Binder { + AuthBinder() { + attachInterface(null, DESCRIPTOR); + } + + @Override + public boolean onTransact(int code, Parcel data, Parcel reply, int flags) + throws RemoteException { + if (code == INTERFACE_TRANSACTION) { + reply.writeString(DESCRIPTOR); + return true; + } + + if (data == null) { + return false; + } + data.enforceInterface(DESCRIPTOR); + switch (code) { + case TRANSACTION_AUTH: + Bundle asyncRequest = readBundle(data); + IBinder callback = data.readStrongBinder(); + Bundle asyncResponse = handleRequest(asyncRequest); + if (callback != null) { + notifyCallback(callback, asyncResponse); + } + return true; + case TRANSACTION_SYNC_AUTH: + Bundle syncRequest = readBundle(data); + Bundle syncResponse = handleRequest(syncRequest); + reply.writeNoException(); + writeBundle(reply, syncResponse); + return true; + default: + return super.onTransact(code, data, reply, flags); + } + } + } + + @Nullable + private static Bundle readBundle(Parcel data) { + if (data.readInt() == 0) { + return null; + } + Bundle bundle = Bundle.CREATOR.createFromParcel(data); + if (bundle != null) { + bundle.setClassLoader(AuthService.class.getClassLoader()); + } + return bundle; + } + + private static void writeBundle(Parcel reply, @Nullable Bundle bundle) { + if (bundle == null) { + reply.writeInt(0); + return; + } + reply.writeInt(1); + bundle.writeToParcel(reply, 0); + } + + private static Bundle handleRequest(@Nullable Bundle request) { + Bundle response = new Bundle(); + response.putInt("result_code", 0); + response.putString("result_msg", "Auth is successful"); + // Stock AuthSession returns the original auth parameters. SystemUI + // uses this nested bundle when deciding whether the focus template is + // allowed to render, so returning an empty result is not equivalent. + response.putBundle("result_auth_params", request == null ? new Bundle() : request); + response.putBundle("result_extra_bundle", new Bundle()); + return response; + } + + private static void notifyCallback(IBinder callback, Bundle response) { + Parcel data = Parcel.obtain(); + try { + data.writeInterfaceToken(CALLBACK_DESCRIPTOR); + writeBundle(data, response); + // The stock callback is declared one-way. Do not make the + // SystemUI binder thread wait for our service process. + callback.transact(1, data, null, IBinder.FLAG_ONEWAY); + } catch (Throwable error) { + // A dead SystemUI callback must never take down the push process. + Log.w(TAG, "Unable to deliver Xiaomi auth callback", error); + } finally { + data.recycle(); + } + } + + @Override + public void onDestroy() { + Log.i(TAG, "Xiaomi auth compatibility service destroyed"); + super.onDestroy(); + } +} diff --git a/push/src/main/java/com/xiaomi/xmsf/FirstRegister.java b/push/src/main/java/com/xiaomi/xmsf/FirstRegister.java index 77b8a84dd..4dc8b637b 100644 --- a/push/src/main/java/com/xiaomi/xmsf/FirstRegister.java +++ b/push/src/main/java/com/xiaomi/xmsf/FirstRegister.java @@ -8,32 +8,42 @@ import android.content.Context; import com.xiaomi.channel.commonutils.logger.MyLog; +import com.xiaomi.channel.commonutils.misc.ScheduledJobManager; import com.xiaomi.mipush.sdk.MiPushClient; import com.xiaomi.xmsf.push.control.PushControllerUtils; -import java.util.Objects; - -public class FirstRegister implements Runnable { +public class FirstRegister extends ScheduledJobManager.Job { + public static final String JOB_ID = "xmsf-first-register"; final Context context; public FirstRegister(Context context) { - this.context = context; + Context applicationContext = context.getApplicationContext(); + this.context = applicationContext == null ? context : applicationContext; + } + + @Override + public String getJobId() { + return JOB_ID; } @Override public void run() { - Objects.requireNonNull(this.context); - MiPushClient.registerPush(this.context, APP_ID, APP_KEY); + if (!PushControllerUtils.isPrefsEnable(this.context)) { + MyLog.i("push disabled, skip initial registration"); + return; + } + boolean registrationStarted = PushControllerUtils.runInitialRegistrationIfEnabled( + () -> MiPushClient.registerPush(this.context, APP_ID, APP_KEY)); + if (!registrationStarted) { + MyLog.i("push disabled while initial registration was starting"); + return; + } if (pushRegistered(this.context)) { + PushControllerUtils.cancelRegistrationRetry(); MyLog.i("register successed"); } else { PushControllerUtils.registerPush(this.context, 0); } - try { - Thread.sleep(100L); - } catch (InterruptedException e) { - MyLog.e("register push interrupted error", e); - } } } diff --git a/push/src/main/java/com/xiaomi/xmsf/MiPushFrameworkApp.java b/push/src/main/java/com/xiaomi/xmsf/MiPushFrameworkApp.java index 80ca91445..dcbab10e1 100644 --- a/push/src/main/java/com/xiaomi/xmsf/MiPushFrameworkApp.java +++ b/push/src/main/java/com/xiaomi/xmsf/MiPushFrameworkApp.java @@ -13,6 +13,7 @@ import android.content.Intent; import android.content.SharedPreferences; import android.os.Build; +import android.os.SystemClock; import androidx.core.app.NotificationChannelCompat; import androidx.core.app.NotificationChannelGroupCompat; @@ -20,13 +21,16 @@ import androidx.core.app.NotificationManagerCompat; import com.elvishew.xlog.XLog; +import com.nihility.Global; import com.nihility.notification.NotificationManagerEx; import com.nihility.utils.Hooker; import com.nihility.utils.PrivilegeElevator; import com.oasisfeng.condom.CondomOptions; import com.oasisfeng.condom.CondomProcess; import com.xiaomi.xmsf.push.control.PushControllerUtils; +import com.xiaomi.xmsf.push.control.StartupWorkPolicy; import com.xiaomi.xmsf.push.control.XMOutbound; +import com.xiaomi.xmsf.push.notification.NotificationController; import com.xiaomi.xmsf.push.service.MiuiPushActivateService; import com.xiaomi.xmsf.utils.LogUtils; @@ -34,6 +38,7 @@ import top.trumeet.common.push.PushServiceAccessibility; import top.trumeet.common.utils.Utils; import top.trumeet.mipush.provider.DatabaseUtils; +import top.trumeet.mipushframework.component.AppIconKt; public class MiPushFrameworkApp extends Application { @@ -63,13 +68,59 @@ public void onCreate() { NotificationManagerEx.init(getApplicationContext()); - installCondom(); - PushControllerUtils.setAllEnable(true, this); + // Follow the master switch so opening a disabled installation does not wake + // scanners or post keep-alive prompts. + if (StartupWorkPolicy.shouldRunAppStartup( + isAppMainProc(this), + PushControllerUtils.isPrefsEnable(this))) { + awakePushActivateService(PushControllerUtils.wrapContext(this)); + requestDozeWhiteList(); + } + } - awakePushActivateServiceOnMainProc(PushControllerUtils.wrapContext(this)); - requestDozeWhiteList(); + @Override + public void onTrimMemory(int level) { + super.onTrimMemory(level); + trimCaches(level); + } + + @Override + public void onLowMemory() { + super.onLowMemory(); + trimCaches(android.content.ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL); + } + + private void trimCaches(int level) { + // All caches are accelerators. Apply the same pressure signal to the + // notification and settings layers without cancelling active delivery. + try { + Global.IconCache().trimMemory(level); + } catch (Throwable error) { + logCacheFailure("Unable to trim shared icon cache", error); + } + try { + Global.ApplicationNameCache().trimMemory(level); + } catch (Throwable error) { + logCacheFailure("Unable to trim application-name cache", error); + } + try { + NotificationController.trimMemory(level); + } catch (Throwable error) { + logCacheFailure("Unable to trim focus-notification image cache", error); + } + try { + AppIconKt.trimIconCache(level); + } catch (Throwable error) { + logCacheFailure("Unable to trim settings UI icon cache", error); + } + } + + private void logCacheFailure(String message, Throwable error) { + if (logger != null) { + logger.w(message, error); + } } private void requestDozeWhiteList() { @@ -83,16 +134,15 @@ private void requestDozeWhiteList() { } } - private void awakePushActivateServiceOnMainProc(Context context) { - if (isAppMainProc(this)) { - long currentTimeMillis = System.currentTimeMillis(); - long elapsedMs = currentTimeMillis - getLastStartupTime(); - int fiveMinutesMs = 300_000; - if (elapsedMs > fiveMinutesMs || elapsedMs < 0) { - setStartupTime(currentTimeMillis); - MiuiPushActivateService.awakePushActivateService( - context, "com.xiaomi.xmsf.push.SCAN"); - } + private void awakePushActivateService(Context context) { + long nowElapsed = SystemClock.elapsedRealtime(); + long previousElapsed = getLastStartupElapsed(); + int fiveMinutesMs = 300_000; + if (StartupWorkPolicy.shouldRunThrottled( + previousElapsed, nowElapsed, fiveMinutesMs)) { + setStartupElapsed(nowElapsed); + MiuiPushActivateService.awakePushActivateService( + context, "com.xiaomi.xmsf.push.SCAN"); } } @@ -113,9 +163,10 @@ private void notifyDozeWhiteListRequest(NotificationManagerCompat manager) { createWarnChannel(manager); Intent removeDozeActivityIntent = new Intent().setComponent( - new ComponentName(Constants.SERVICE_APP_NAME, Constants.REMOVE_DOZE_COMPONENT_NAME)); + new ComponentName(getPackageName(), RemoveDozeActivity.class.getName())); PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, - removeDozeActivityIntent, PendingIntent.FLAG_UPDATE_CURRENT); + removeDozeActivityIntent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); Notification notification = new NotificationCompat.Builder(this, CHANNEL_WARN) .setContentInfo(getString(R.string.wizard_title_doze_whitelist)) .setContentTitle(getString(R.string.wizard_title_doze_whitelist)) @@ -133,11 +184,11 @@ private void notifyDozeWhiteListRequest(NotificationManagerCompat manager) { private void createWarnChannel(NotificationManagerCompat manager) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { NotificationChannelCompat.Builder channel = new NotificationChannelCompat - .Builder(CHANNEL_WARN, NotificationManager.IMPORTANCE_HIGH) - .setName(getString(R.string.wizard_title_doze_whitelist)); + .Builder(CHANNEL_WARN, NotificationManager.IMPORTANCE_HIGH) + .setName(getString(R.string.wizard_title_doze_whitelist)); NotificationChannelGroupCompat notificationChannelGroup = - new NotificationChannelGroupCompat.Builder(CHANNEL_WARN).setName(CHANNEL_WARN).build(); + new NotificationChannelGroupCompat.Builder(CHANNEL_WARN).setName(CHANNEL_WARN).build(); manager.createNotificationChannelGroup(notificationChannelGroup); channel.setGroup(notificationChannelGroup.getId()); manager.createNotificationChannel(channel.build()); @@ -145,12 +196,15 @@ private void createWarnChannel(NotificationManagerCompat manager) { } - private long getLastStartupTime() { - return getDefaultPreferences().getLong("xmsf_startup", 0); + private long getLastStartupElapsed() { + return getDefaultPreferences().getLong("xmsf_startup_elapsed", 0); } - private boolean setStartupTime(long j) { - return getDefaultPreferences().edit().putLong("xmsf_startup", j).commit(); + private void setStartupElapsed(long elapsedRealtime) { + getDefaultPreferences().edit() + .putLong("xmsf_startup_elapsed", elapsedRealtime) + .remove("xmsf_startup") + .apply(); } private SharedPreferences getDefaultPreferences() { diff --git a/push/src/main/java/com/xiaomi/xmsf/NotificationClickActivity.java b/push/src/main/java/com/xiaomi/xmsf/NotificationClickActivity.java new file mode 100644 index 000000000..b87dfeee7 --- /dev/null +++ b/push/src/main/java/com/xiaomi/xmsf/NotificationClickActivity.java @@ -0,0 +1,496 @@ +package com.xiaomi.xmsf; + +import android.app.Activity; +import android.app.ActivityManager; +import android.app.KeyguardManager; +import android.content.ActivityNotFoundException; +import android.content.ComponentName; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.os.PowerManager; +import android.os.SystemClock; +import android.util.Log; + +import androidx.annotation.Nullable; + +import com.xiaomi.push.sdk.MyPushMessageHandler; +import com.xiaomi.push.sdk.NotificationClickHandoffPolicy; +import com.xiaomi.push.sdk.TargetSdkClickDispatcher; +import com.xiaomi.push.service.MyMIPushNotificationHelper; +import com.xiaomi.push.service.PushConstants; +import com.xiaomi.xmpush.thrift.XmPushActionContainer; + +import com.nihility.XMPushUtils; + +import java.util.List; + +import top.trumeet.common.override.ActivityManagerOverride; + +/** + * User-initiated notification click hand-off. + * + *

The notification is posted by XMSF, but the original MiPush SDK click + * contract has two parts: it wakes the target application and forwards the + * complete message to that application's {@code PushMessageHandler}. Starting + * the target Activity directly skips the second part and breaks SDK bridge + * Activities (for example a vendor's notification proxy). This transparent + * Activity is the common hand-off point for every package; it contains no + * package-specific routing.

+ */ +public final class NotificationClickActivity extends Activity { + public static final String EXTRA_TARGET_INTENT = + "com.xiaomi.xmsf.extra.NOTIFICATION_TARGET_INTENT"; + public static final String EXTRA_SERVICE_INTENT = + "com.xiaomi.xmsf.extra.NOTIFICATION_SERVICE_INTENT"; + public static final String EXTRA_TARGET_ACTIVITY_PRIVATE = + "com.xiaomi.xmsf.extra.NOTIFICATION_TARGET_ACTIVITY_PRIVATE"; + public static final String EXTRA_MANUAL_REPLAY = + "com.xiaomi.xmsf.extra.NOTIFICATION_MANUAL_REPLAY"; + public static final String EXTRA_TARGET_PACKAGE = + "com.xiaomi.xmsf.extra.NOTIFICATION_TARGET_PACKAGE"; + + private static final String TAG = "MiPushClick"; + private static final long TARGET_VISIBILITY_TIMEOUT_MS = 2_000L; + private static final long TARGET_VISIBILITY_POLL_MS = 50L; + + private final Handler mainHandler = new Handler(Looper.getMainLooper()); + + @Nullable private Intent pendingClickIntent; + @Nullable private Intent pendingServiceIntent; + @Nullable private Intent pendingTargetIntent; + @Nullable private byte[] pendingPayload; + @Nullable private XmPushActionContainer pendingContainer; + @Nullable private String pendingTargetPackage; + private boolean pendingTargetActivityPrivate; + private boolean pendingManualReplay; + private boolean waitingForTargetNavigation; + private boolean sdkDispatchAttempted; + private boolean clickFinished; + private boolean notificationCancelled; + private boolean stopped; + private long sdkDispatchStartedAt; + + private final Runnable targetVisibilityProbe = new Runnable() { + @Override + public void run() { + if (!waitingForTargetNavigation || clickFinished) { + return; + } + boolean userPresent = isUserPresent(); + boolean targetVisible = userPresent && (isTargetTaskVisible() || stopped); + boolean timedOut = SystemClock.uptimeMillis() - sdkDispatchStartedAt + >= TARGET_VISIBILITY_TIMEOUT_MS; + NotificationClickHandoffPolicy.Action action = + NotificationClickHandoffPolicy.afterNavigationProbe( + targetVisible, timedOut, userPresent); + if (action == NotificationClickHandoffPolicy.Action.FINISH) { + finishAfterClick(targetVisible ? "TARGET_UI_VISIBLE" : "USER_NOT_PRESENT"); + } else if (action == NotificationClickHandoffPolicy.Action.START_FALLBACK) { + startFallbackAndFinish("TARGET_UI_TIMEOUT"); + } else { + mainHandler.postDelayed(this, TARGET_VISIBILITY_POLL_MS); + } + } + }; + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + dispatchClick(getIntent()); + } + + @Override + protected void onStop() { + super.onStop(); + stopped = true; + // An opaque target Activity stops this transparent trampoline. The + // process-importance probe covers target Activities that only pause it. + if (waitingForTargetNavigation && !clickFinished && isUserPresent()) { + mainHandler.post(targetVisibilityProbe); + } + } + + @Override + protected void onStart() { + super.onStart(); + stopped = false; + } + + @Override + public void onWindowFocusChanged(boolean hasFocus) { + super.onWindowFocusChanged(hasFocus); + if (!hasFocus && waitingForTargetNavigation && !clickFinished) { + mainHandler.post(targetVisibilityProbe); + } + } + + @Override + protected void onDestroy() { + mainHandler.removeCallbacks(targetVisibilityProbe); + super.onDestroy(); + } + + private void dispatchClick(@Nullable Intent clickIntent) { + if (clickIntent == null) { + finishClickTask(); + return; + } + + Intent serviceIntent = getParcelable(clickIntent, EXTRA_SERVICE_INTENT); + if (serviceIntent == null) { + serviceIntent = clickIntent.getParcelableExtra("mipush_serviceIntent"); + } + byte[] payload = clickIntent.getByteArrayExtra(PushConstants.MIPUSH_EXTRA_PAYLOAD); + if (payload == null && serviceIntent != null) { + payload = serviceIntent.getByteArrayExtra(PushConstants.MIPUSH_EXTRA_PAYLOAD); + } + XmPushActionContainer container = null; + if (payload != null && payload.length > 0) { + try { + container = XMPushUtils.packToContainer(payload); + } catch (Throwable decodeError) { + Log.w(TAG, "unable to decode notification click payload", decodeError); + } + } + Intent targetIntent = getParcelable(clickIntent, EXTRA_TARGET_INTENT); + boolean targetActivityPrivate = clickIntent.getBooleanExtra( + EXTRA_TARGET_ACTIVITY_PRIVATE, false); + boolean manualReplay = clickIntent.getBooleanExtra(EXTRA_MANUAL_REPLAY, false); + + pendingClickIntent = clickIntent; + pendingServiceIntent = serviceIntent; + pendingTargetIntent = targetIntent; + pendingPayload = payload; + pendingContainer = container; + pendingTargetActivityPrivate = targetActivityPrivate; + pendingManualReplay = manualReplay; + pendingTargetPackage = resolveTargetPackage(clickIntent, container, targetIntent); + + NotificationClickHandoffPolicy.Action initialAction = + NotificationClickHandoffPolicy.initialAction( + manualReplay, targetActivityPrivate); + if (initialAction == NotificationClickHandoffPolicy.Action.DISPATCH_SDK_FIRST) { + dispatchSdkFirst(); + } else { + startDirectTargetAndFinish(); + } + } + + private void dispatchSdkFirst() { + if (sdkDispatchAttempted || clickFinished) { + return; + } + sdkDispatchAttempted = true; + TargetSdkClickDispatcher.DispatchResult result = + TargetSdkClickDispatcher.DispatchResult.UNAVAILABLE; + + try { + if (pendingManualReplay) { + result = TargetSdkClickDispatcher.dispatchReplay(this, pendingContainer); + } else if (pendingContainer != null && pendingPayload != null) { + result = TargetSdkClickDispatcher.dispatchPayload( + this, pendingContainer, pendingPayload); + } + } catch (Throwable error) { + Log.w(TAG, "target SDK click delivery failed", error); + result = TargetSdkClickDispatcher.DispatchResult.FAILED; + } + + boolean targetVisible = isUserPresent() && (isTargetTaskVisible() || stopped); + NotificationClickHandoffPolicy.Action action = + NotificationClickHandoffPolicy.afterSdkDispatch( + result.isAccepted(), targetVisible); + Log.i(TAG, "SDK-first click delivery: " + result + ", next=" + action); + if (action == NotificationClickHandoffPolicy.Action.FINISH) { + finishAfterClick("TARGET_ALREADY_VISIBLE"); + } else if (action == NotificationClickHandoffPolicy.Action.START_FALLBACK) { + startFallbackAndFinish("SDK_DELIVERY_" + result); + } else { + cancelClickedNotification(); + sdkDispatchStartedAt = SystemClock.uptimeMillis(); + waitingForTargetNavigation = true; + mainHandler.post(targetVisibilityProbe); + } + } + + private void startDirectTargetAndFinish() { + if (clickFinished) { + return; + } + try { + if (pendingClickIntent != null) { + startTargetActivity( + pendingTargetIntent, pendingClickIntent, pendingContainer, + pendingTargetActivityPrivate); + } + } catch (Throwable error) { + Log.w(TAG, "direct notification click route failed", error); + } finally { + finishAfterClick("DIRECT_TARGET"); + } + } + + private void startFallbackAndFinish(String reason) { + if (clickFinished) { + return; + } + waitingForTargetNavigation = false; + mainHandler.removeCallbacks(targetVisibilityProbe); + boolean started = false; + try { + if (pendingManualReplay) { + started = startTargetLauncher(pendingTargetPackage); + } else if (pendingClickIntent != null) { + started = startTargetActivity( + pendingTargetIntent, pendingClickIntent, pendingContainer, + pendingTargetActivityPrivate); + } + if (!started) { + started = startTargetLauncher(pendingTargetPackage); + } + } catch (Throwable error) { + Log.w(TAG, "notification click fallback failed", error); + } + Log.w(TAG, reason + ": target fallback started=" + started); + finishAfterClick(reason); + } + + private void finishAfterClick(String reason) { + if (clickFinished) { + return; + } + clickFinished = true; + waitingForTargetNavigation = false; + mainHandler.removeCallbacks(targetVisibilityProbe); + Log.i(TAG, "notification click hand-off complete: " + reason); + cancelClickedNotification(); + finishClickTask(); + } + + private void cancelClickedNotification() { + if (notificationCancelled) { + return; + } + notificationCancelled = true; + Intent clickIntent = pendingClickIntent; + Intent serviceIntent = pendingServiceIntent; + XmPushActionContainer container = pendingContainer; + Bundle notificationExtras = serviceIntent != null + ? serviceIntent.getExtras() + : (clickIntent == null ? null : clickIntent.getExtras()); + if (container != null && notificationExtras != null) { + try { + MyPushMessageHandler.cancelNotification(this, notificationExtras, container); + } catch (Throwable error) { + Log.w(TAG, "unable to cancel clicked notification", error); + } + } + } + + /** Replay payloads may contain stale vendor bridge tokens; failure opens only the app root. */ + private boolean startTargetLauncher(@Nullable String targetPackage) { + if (targetPackage == null || targetPackage.equals(getPackageName())) { + return false; + } + Intent launch = getPackageManager().getLaunchIntentForPackage(targetPackage); + if (launch == null) { + return false; + } + ResolveInfo resolved = getPackageManager().resolveActivity( + launch, PackageManager.MATCH_DEFAULT_ONLY); + if (resolved == null || resolved.activityInfo == null + || !resolved.activityInfo.exported + || !resolved.activityInfo.enabled + || (resolved.activityInfo.applicationInfo != null + && !resolved.activityInfo.applicationInfo.enabled) + || !targetPackage.equals(resolved.activityInfo.packageName)) { + return false; + } + launch.setComponent(new ComponentName( + resolved.activityInfo.packageName, resolved.activityInfo.name)); + launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK + | Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED); + try { + startActivity(launch); + return true; + } catch (ActivityNotFoundException | SecurityException error) { + Log.w(TAG, "unable to prime target launcher: " + targetPackage, error); + return false; + } + } + + private boolean isTargetTaskVisible() { + if (pendingTargetPackage == null) { + return false; + } + ActivityManager activityManager = getSystemService(ActivityManager.class); + if (activityManager == null) { + return false; + } + try { + // XMSF commonly has system-level task visibility. Prefer the actual + // top Activity because a process executing a broadcast receiver is + // also reported as IMPORTANCE_FOREGROUND even when it has no UI. + @SuppressWarnings("deprecation") + List tasks = + activityManager.getRunningTasks(1); + if (tasks != null && !tasks.isEmpty()) { + ComponentName topActivity = tasks.get(0).topActivity; + if (topActivity != null + && pendingTargetPackage.equals(topActivity.getPackageName())) { + return true; + } + } + int importance = ActivityManagerOverride.getPackageImportance( + pendingTargetPackage, activityManager); + // VISIBLE is useful for translucent target Activities. Do not use + // FOREGROUND here: that may only be the SDK receiver processing the + // payload and would suppress the bounded launcher fallback. + return importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_VISIBLE; + } catch (Throwable unavailable) { + // Usage access and hidden-API availability differ across third-party + // ROMs. onStop remains the portable opaque-Activity readiness signal. + return false; + } + } + + private boolean isUserPresent() { + PowerManager powerManager = getSystemService(PowerManager.class); + KeyguardManager keyguardManager = getSystemService(KeyguardManager.class); + return powerManager != null && powerManager.isInteractive() + && (keyguardManager == null || !keyguardManager.isKeyguardLocked()); + } + + @Nullable + private String resolveTargetPackage( + Intent clickIntent, + @Nullable XmPushActionContainer container, + @Nullable Intent targetIntent) { + String trustedPackage = clickIntent.getStringExtra(EXTRA_TARGET_PACKAGE); + String targetPackage = MyMIPushNotificationHelper + .getNotificationTargetPackage(container); + if (container != null && targetPackage != null && !targetPackage.isEmpty()) { + if (trustedPackage != null && !trustedPackage.isEmpty() + && !trustedPackage.equals(targetPackage)) { + Log.w(TAG, "target package marker does not match payload"); + return null; + } + return targetPackage; + } + if (trustedPackage != null && !trustedPackage.isEmpty()) { + return trustedPackage; + } + ComponentName targetComponent = targetIntent == null ? null : targetIntent.getComponent(); + if (targetComponent != null) { + return targetComponent.getPackageName(); + } + return targetIntent == null ? null : targetIntent.getPackage(); + } + + private void finishClickTask() { + // A plain finish keeps the target application's task untouched if its + // SDK reparented a bridge Activity during this user-initiated hand-off. + finish(); + } + + private boolean startTargetActivity( + @Nullable Intent targetIntent, + Intent clickIntent, + @Nullable XmPushActionContainer container, + boolean targetActivityPrivate) { + Intent launch = targetActivityPrivate + ? resolveExportedFallback(targetIntent, container) + : (targetIntent == null ? null : new Intent(targetIntent)); + if (launch == null && container != null) { + String targetPackage = MyMIPushNotificationHelper + .getNotificationTargetPackage(container); + if (targetPackage != null && !targetPackage.isEmpty()) { + launch = getPackageManager().getLaunchIntentForPackage(targetPackage); + } + } + if (launch == null) { + return false; + } + + if (targetActivityPrivate) { + Log.i(TAG, "private notification route replaced with exported target: " + + launch.getComponent()); + } + + Intent serviceIntent = getParcelable(clickIntent, EXTRA_SERVICE_INTENT); + if (serviceIntent == null) { + serviceIntent = clickIntent.getParcelableExtra("mipush_serviceIntent"); + } + if (serviceIntent != null) { + // Preserve the SDK's historical bridge extras for target proxy + // Activities. They are opaque to XMSF and therefore work for every + // client without an application-specific adapter. + launch.putExtra("mipush_serviceIntent", serviceIntent); + launch.putExtras(serviceIntent); + } + launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + try { + startActivity(launch); + return true; + } catch (ActivityNotFoundException error) { + ComponentName component = launch.getComponent(); + Log.w(TAG, "target Activity not found: " + component, error); + throw error; + } + } + + /** + * Resolve a user-visible route without ever attempting to start the + * sender's private proxy Activity. Clearing an explicit component keeps + * its action/data/extras (for example a vendor deep link) and lets the + * package manager select an exported handler in the same target package. + * If no such handler exists, the caller falls back to the package launcher. + */ + @Nullable + private Intent resolveExportedFallback( + @Nullable Intent targetIntent, + @Nullable XmPushActionContainer container) { + if (targetIntent == null) { + return null; + } + + String targetPackage = MyMIPushNotificationHelper + .getNotificationTargetPackage(container); + ComponentName explicit = targetIntent.getComponent(); + if ((targetPackage == null || targetPackage.isEmpty()) && explicit != null) { + targetPackage = explicit.getPackageName(); + } + + Intent candidate = new Intent(targetIntent); + if (explicit != null) { + // Do not leak a cross-package component from malformed payloads. + if (targetPackage == null || !targetPackage.equals(explicit.getPackageName())) { + return null; + } + candidate.setComponent(null); + candidate.setPackage(targetPackage); + } + + ResolveInfo resolved = getPackageManager().resolveActivity( + candidate, android.content.pm.PackageManager.MATCH_DEFAULT_ONLY); + if (resolved == null || resolved.activityInfo == null + || !resolved.activityInfo.exported + || (targetPackage != null + && !targetPackage.equals(resolved.activityInfo.packageName))) { + return null; + } + candidate.setComponent(new ComponentName( + resolved.activityInfo.packageName, resolved.activityInfo.name)); + return candidate; + } + + @SuppressWarnings("deprecation") + @Nullable + private static Intent getParcelable(Intent source, String key) { + return source.getParcelableExtra(key); + } +} diff --git a/push/src/main/java/com/xiaomi/xmsf/RetryRegister.java b/push/src/main/java/com/xiaomi/xmsf/RetryRegister.java index e58b4f356..c2eb454b2 100644 --- a/push/src/main/java/com/xiaomi/xmsf/RetryRegister.java +++ b/push/src/main/java/com/xiaomi/xmsf/RetryRegister.java @@ -15,24 +15,35 @@ public class RetryRegister implements Runnable { final int tryRegisterCount; - final Context context; + final Context context; + final long generation; - public RetryRegister(Context context, int i) { - this.context = context; + public RetryRegister(Context context, int i, long generation) { + Context applicationContext = context.getApplicationContext(); + this.context = applicationContext == null ? context : applicationContext; this.tryRegisterCount = i; + this.generation = generation; } @Override public void run() { + if (!PushControllerUtils.beginRegistrationRetry(this, generation)) { + return; + } if (pushRegistered(this.context)) { + PushControllerUtils.cancelRegistrationRetry(); MyLog.i("register successed, stop retry"); return; } - MiPushClient.registerPush(this.context, APP_ID, APP_KEY); + boolean registrationStarted = PushControllerUtils.runRegistrationRetryIfActive( + generation, () -> MiPushClient.registerPush(this.context, APP_ID, APP_KEY)); + if (!registrationStarted) { + return; + } int tryRegisterCount = this.tryRegisterCount + 1; if (tryRegisterCount <= 10) { MyLog.i("register not successed, register again, retryIndex: " + tryRegisterCount); - PushControllerUtils.registerPush(this.context, tryRegisterCount); + PushControllerUtils.registerPush(this.context, tryRegisterCount, generation); return; } MyLog.i("register not successed, but retry to many times, stop retry"); diff --git a/push/src/main/java/com/xiaomi/xmsf/SettingUtils.java b/push/src/main/java/com/xiaomi/xmsf/SettingUtils.java index bddc0e073..77ab7acf2 100644 --- a/push/src/main/java/com/xiaomi/xmsf/SettingUtils.java +++ b/push/src/main/java/com/xiaomi/xmsf/SettingUtils.java @@ -62,17 +62,39 @@ public static void clearHistory(Context context) { } public static void startMiPushServiceAsForegroundService(Context context) { - new InternalMessenger(context).send(new Intent(XMPushServiceMessenger.IntentStartForeground)); + try (InternalMessenger messenger = new InternalMessenger(context)) { + messenger.send(new Intent(XMPushServiceMessenger.IntentStartForeground)); + } } public static void notifyMockNotification(Context context) { String packageName = BuildConfig.APPLICATION_ID; + if (!NotificationController.areNotificationsEnabled(context, packageName)) { + Utils.makeText(context, + context.getString(R.string.settings_notification_permission_blocked), + Toast.LENGTH_LONG); + return; + } Date date = new Date(); String title = context.getString(R.string.debug_test_title); String description = context.getString(R.string.debug_test_content) + date.toString(); NotificationController.test(context, packageName, title, description); } + public static void notifyMockFocusNotification(Context context) { + String packageName = BuildConfig.APPLICATION_ID; + if (!NotificationController.areNotificationsEnabled(context, packageName)) { + Utils.makeText(context, + context.getString(R.string.settings_notification_permission_blocked), + Toast.LENGTH_LONG); + return; + } + Date date = new Date(); + String title = context.getString(R.string.debug_test_focus_title); + String description = context.getString(R.string.debug_test_focus_content) + date; + NotificationController.testFocus(context, packageName, title, description); + } + public static boolean isIceBoxInstalled() { return Utils.isAppInstalled(IceBox.PACKAGE_NAME); } @@ -86,8 +108,9 @@ public static void tryForceRegisterAllApplications() { } public static void sendXMPPReconnectRequest(Context context) { - new InternalMessenger(context).send( - new Intent(PushConstants.ACTION_RESET_CONNECTION)); + try (InternalMessenger messenger = new InternalMessenger(context)) { + messenger.send(new Intent(PushConstants.ACTION_RESET_CONNECTION)); + } } public static void setXMPPServer(Context context, String newHost) { @@ -108,8 +131,7 @@ public static Uri getConfigurationDirectory(Context context) { public static void shareLogs(Context context) { context.startActivity(new Intent() - .setComponent(new ComponentName(Constants.SERVICE_APP_NAME, - Constants.SHARE_LOG_COMPONENT_NAME))); + .setComponent(new ComponentName(context, ShareLogActivity.class))); } public static @NonNull Uri saveConfigurationUri(Context context, Intent data) { @@ -126,4 +148,4 @@ public static void setConfigurationDirectory(Context context, Uri uri) { Global.ConfigCenter().loadConfigurations(context); } -} \ No newline at end of file +} diff --git a/push/src/main/java/com/xiaomi/xmsf/push/control/PushControllerUtils.java b/push/src/main/java/com/xiaomi/xmsf/push/control/PushControllerUtils.java index 577b2df65..8db4d4833 100644 --- a/push/src/main/java/com/xiaomi/xmsf/push/control/PushControllerUtils.java +++ b/push/src/main/java/com/xiaomi/xmsf/push/control/PushControllerUtils.java @@ -19,21 +19,20 @@ import android.preference.PreferenceManager; import android.text.TextUtils; -import androidx.core.content.ContextCompat; - import com.elvishew.xlog.Logger; import com.elvishew.xlog.XLog; import com.oasisfeng.condom.CondomContext; import com.xiaomi.channel.commonutils.logger.MyLog; import com.xiaomi.channel.commonutils.misc.ScheduledJobManager; import com.xiaomi.mipush.sdk.MiPushClient; -import com.xiaomi.push.service.PushServiceConstants; import com.xiaomi.xmsf.FirstRegister; import com.xiaomi.xmsf.RetryRegister; import com.xiaomi.xmsf.push.service.receivers.BootReceiver; import com.xiaomi.xmsf.push.service.receivers.KeepAliveReceiver; +import java.util.List; import java.util.Objects; +import java.util.concurrent.atomic.AtomicBoolean; import top.trumeet.common.Constants; @@ -47,17 +46,86 @@ public class PushControllerUtils { private static Logger logger = XLog.tag(PushControllerUtils.class.getSimpleName()).build(); - private static BroadcastReceiver liveReceiver = new KeepAliveReceiver(); + private static final Object LIVE_RECEIVER_LOCK = new Object(); + private static BroadcastReceiver liveReceiver; + private static Context liveReceiverContext; + private static final AtomicBoolean PUSH_SERVICE_RUNNING = new AtomicBoolean(); + private static final RegistrationRetryCoordinator REGISTRATION_RETRIES = + new RegistrationRetryCoordinator(new RegistrationRetryCoordinator.Scheduler() { + private Handler handler; + + private synchronized Handler handler() { + if (handler == null) { + handler = new Handler(Looper.getMainLooper()); + } + return handler; + } + + @Override + public boolean postDelayed(Runnable task, long delayMs) { + return handler().postDelayed(task, delayMs); + } + + @Override + public void removeCallbacks(Runnable task) { + handler().removeCallbacks(task); + } + }); private static final int[] RetryInterval = {3600000, 7200000, 14400000, 28800000, 86400000}; public static void registerPush(Context context, int i) { + scheduleRegistrationRetry(context, i, null); + } + + public static void registerPush(Context context, int i, long generation) { + scheduleRegistrationRetry(context, i, generation); + } + + private static void scheduleRegistrationRetry(Context context, int i, + Long expectedGeneration) { Objects.requireNonNull(context); int[] retryInterval = RetryInterval; int length = retryInterval.length; - long intervalMs = i < length ? retryInterval[i] : retryInterval[length - 1]; - MyLog.i("for make sure xmsf register push succ, schedule register after " + intervalMs / 1000 + " sec"); - new Handler(Looper.getMainLooper()).postDelayed(new RetryRegister(context, i), intervalMs); + int retryIndex = Math.max(0, i); + long intervalMs = retryIndex < length + ? retryInterval[retryIndex] : retryInterval[length - 1]; + Context applicationContext = context.getApplicationContext(); + if (applicationContext == null) { + applicationContext = context; + } + Context retryContext = applicationContext; + boolean scheduled = expectedGeneration == null + ? REGISTRATION_RETRIES.schedule(intervalMs, + generation -> new RetryRegister(retryContext, retryIndex, generation)) + : REGISTRATION_RETRIES.schedule(intervalMs, expectedGeneration, + generation -> new RetryRegister(retryContext, retryIndex, generation)); + if (scheduled) { + MyLog.i("for make sure xmsf register push succ, schedule register after " + + intervalMs / 1000 + " sec"); + } else { + MyLog.i("registration retry already pending or disabled, skip duplicate schedule"); + } + } + + public static boolean beginRegistrationRetry(RetryRegister retry, long generation) { + return REGISTRATION_RETRIES.begin(retry, generation); + } + + public static boolean runRegistrationRetryIfActive(long generation, Runnable action) { + return REGISTRATION_RETRIES.runIfActive(generation, action); + } + + public static boolean runInitialRegistrationIfEnabled(Runnable action) { + return REGISTRATION_RETRIES.runIfEnabled(action); + } + + public static void cancelRegistrationRetry() { + REGISTRATION_RETRIES.cancelPending(); + } + + public static boolean isRegistrationRetryEnabled() { + return REGISTRATION_RETRIES.isEnabled(); } public static boolean pushRegistered(final Context context) { @@ -65,7 +133,9 @@ public static boolean pushRegistered(final Context context) { } private static SharedPreferences getPrefs(Context context) { - return PreferenceManager.getDefaultSharedPreferences(context.getApplicationContext()); + Context applicationContext = context.getApplicationContext(); + return PreferenceManager.getDefaultSharedPreferences( + applicationContext == null ? context : applicationContext); } /** @@ -101,12 +171,28 @@ public static void setPrefsEnable(boolean value, Context context) { * @return is in main processMIPushMessage */ public static boolean isAppMainProc(Context context) { - for (ActivityManager.RunningAppProcessInfo runningAppProcessInfo : ((ActivityManager) - context.getSystemService(Context.ACTIVITY_SERVICE)) - .getRunningAppProcesses()) { - if (runningAppProcessInfo.pid == Process.myPid() && runningAppProcessInfo.processName.equals(context.getPackageName())) { - return true; + try { + ActivityManager activityManager = (ActivityManager) + context.getSystemService(Context.ACTIVITY_SERVICE); + if (activityManager == null) { + return false; + } + List processes = + activityManager.getRunningAppProcesses(); + if (processes == null) { + return false; } + for (ActivityManager.RunningAppProcessInfo runningAppProcessInfo + : processes) { + if (runningAppProcessInfo != null + && runningAppProcessInfo.pid == Process.myPid() + && TextUtils.equals(runningAppProcessInfo.processName, + context.getPackageName())) { + return true; + } + } + } catch (RuntimeException e) { + logger.w("Unable to inspect application process", e); } return false; } @@ -118,46 +204,37 @@ public static boolean isAppMainProc(Context context) { * @param context context param */ public static void setServiceEnable(boolean enable, Context context) { + Context applicationContext = context.getApplicationContext(); + if (applicationContext == null) { + applicationContext = context; + } + context = applicationContext; if (enable) { + REGISTRATION_RETRIES.enable(); logger.d("Starting..."); - if (isAppMainProc(context)) { ScheduledJobManager.getInstance(wrapContext(context)) .addOneShootJob(new FirstRegister(wrapContext(context))); } - try { - Intent serviceIntent = new Intent(context, com.xiaomi.push.service.XMPushService.class); - serviceIntent.putExtra(PushServiceConstants.EXTRA_TIME_STAMP, System.currentTimeMillis()); - serviceIntent.setAction(PushServiceConstants.ACTION_TIMER); - ContextCompat.startForegroundService(context, serviceIntent); - } catch (Throwable e) { - logger.e(e); - } - - try { - IntentFilter filter = new IntentFilter(); - filter.addAction(Intent.ACTION_SCREEN_ON); - context.registerReceiver(liveReceiver, filter); - } catch (Throwable e) { - logger.e(e); - } - + PushServiceDispatcher.dispatchStart(context, true); } else { + REGISTRATION_RETRIES.disable(); logger.d("Stopping..."); - try { - context.unregisterReceiver(liveReceiver); - } catch (Throwable e) { - logger.e(e); - } + ScheduledJobManager.getInstance(wrapContext(context)) + .cancelJob(FirstRegister.JOB_ID); + + unregisterLiveReceiver(); MiPushClient.unregisterPush(wrapContext(context)); // Force stop and disable services. if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { JobScheduler scheduler = (JobScheduler) context.getSystemService(Context.JOB_SCHEDULER_SERVICE); - scheduler.cancelAll(); + if (scheduler != null) { + scheduler.cancelAll(); + } } context.stopService(new Intent(context, com.xiaomi.push.service.XMPushService.class)); } @@ -190,4 +267,54 @@ public static Context wrapContext(final Context context) { return CondomContext.wrap(context, TAG_CONDOM, XMOutbound.create(context, TAG_CONDOM)); } + + /** Returns lifecycle state reported by the in-process push service hook. */ + public static boolean isPushServiceRunning() { + return PUSH_SERVICE_RUNNING.get(); + } + + public static void onPushServiceCreated() { + PUSH_SERVICE_RUNNING.set(true); + } + + public static void onPushServiceDestroyed() { + PUSH_SERVICE_RUNNING.set(false); + } + + public static void registerLiveReceiver(Context context) { + Context applicationContext = context.getApplicationContext(); + if (applicationContext == null) { + applicationContext = context; + } + synchronized (LIVE_RECEIVER_LOCK) { + if (liveReceiverContext != null) { + return; + } + BroadcastReceiver receiver = new KeepAliveReceiver(); + IntentFilter filter = new IntentFilter(Intent.ACTION_SCREEN_ON); + try { + applicationContext.registerReceiver(receiver, filter); + liveReceiver = receiver; + liveReceiverContext = applicationContext; + } catch (Throwable e) { + logger.e("Unable to register screen-on recovery receiver", e); + } + } + } + + public static void unregisterLiveReceiver() { + synchronized (LIVE_RECEIVER_LOCK) { + if (liveReceiverContext == null || liveReceiver == null) { + return; + } + try { + liveReceiverContext.unregisterReceiver(liveReceiver); + } catch (Throwable e) { + logger.e("Unable to unregister screen-on recovery receiver", e); + } finally { + liveReceiver = null; + liveReceiverContext = null; + } + } + } } diff --git a/push/src/main/java/com/xiaomi/xmsf/push/control/PushServiceDispatcher.java b/push/src/main/java/com/xiaomi/xmsf/push/control/PushServiceDispatcher.java new file mode 100644 index 000000000..91c53eacc --- /dev/null +++ b/push/src/main/java/com/xiaomi/xmsf/push/control/PushServiceDispatcher.java @@ -0,0 +1,150 @@ +package com.xiaomi.xmsf.push.control; + +import android.app.ActivityManager; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.os.Build; +import android.os.Process; +import android.text.TextUtils; + +import androidx.core.content.ContextCompat; + +import com.elvishew.xlog.Logger; +import com.elvishew.xlog.XLog; +import com.nihility.Global; +import com.xiaomi.push.service.PushServiceConstants; +import com.xiaomi.xmsf.utils.ConfigCenter; + +import java.util.List; + +public class PushServiceDispatcher { + private static final Logger logger = XLog.tag(PushServiceDispatcher.class.getSimpleName()).build(); + + public static PushServiceStartPolicy.Action dispatchStart(Context context, boolean userInitiated) { + return dispatchIntent(context, null, userInitiated); + } + + /** + * Dispatch a recovery/start request while preserving the SDK's action + * contract (for example network-status and check-alive are not timers). + */ + public static PushServiceStartPolicy.Action dispatchStart( + Context context, String action, boolean userInitiated) { + Intent sourceIntent = new Intent(); + sourceIntent.setAction(action); + return dispatchIntent(context, sourceIntent, userInitiated); + } + + /** + * Start the transport while preserving the SDK action and all extras. This + * is the single gate used by recovery receivers and the bridge service. + */ + public static PushServiceStartPolicy.Action dispatchIntent( + Context context, Intent sourceIntent, boolean userInitiated) { + if (context == null) { + return PushServiceStartPolicy.Action.SKIP; + } + Context appContext = context.getApplicationContext() != null ? context.getApplicationContext() : context; + boolean masterEnabled = PushControllerUtils.isPrefsEnable(appContext); + boolean serviceRunning = PushControllerUtils.isPushServiceRunning(); + boolean persistentForeground = isPersistentForegroundEnabled( + appContext, Global.ConfigCenter()); + boolean platformAllowed = isPlatformStartAllowed(appContext); + + PushServiceStartPolicy.Action action = PushServiceStartPolicy.evaluate( + masterEnabled, + serviceRunning, + userInitiated, + persistentForeground, + platformAllowed + ); + + logger.i("PushServiceDispatcher evaluated action: " + action + " (master=" + masterEnabled + + ", running=" + serviceRunning + ", userInit=" + userInitiated + + ", fgsPref=" + persistentForeground + ", platformAllowed=" + platformAllowed + ")"); + + switch (action) { + case START_FOREGROUND: + startForegroundServiceSafely(appContext, sourceIntent); + break; + case START_SERVICE: + startServiceSafely(appContext, sourceIntent); + break; + case SKIP: + default: + break; + } + return action; + } + + static boolean isPersistentForegroundEnabled(Context context, ConfigCenter configCenter) { + return configCenter.isStartForegroundService(context); + } + + private static boolean isPlatformStartAllowed(Context context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + return true; + } + try { + ActivityManager am = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE); + if (am == null) { + return true; + } + List procs = am.getRunningAppProcesses(); + if (procs != null) { + int pid = Process.myPid(); + for (ActivityManager.RunningAppProcessInfo info : procs) { + if (info.pid == pid) { + return info.importance <= ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND_SERVICE; + } + } + } + } catch (Throwable e) { + logger.w("Unable to determine process importance", e); + } + return false; + } + + static Intent createPushServiceIntent(Context context, Intent sourceIntent) { + Intent intent = sourceIntent == null + ? new Intent(PushServiceConstants.ACTION_TIMER) + : new Intent(sourceIntent); + intent.setComponent(new ComponentName(context, com.xiaomi.push.service.XMPushService.class)); + if (TextUtils.isEmpty(intent.getAction())) { + intent.setAction(PushServiceConstants.ACTION_TIMER); + } + if (!intent.hasExtra(PushServiceConstants.EXTRA_TIME_STAMP)) { + intent.putExtra(PushServiceConstants.EXTRA_TIME_STAMP, System.currentTimeMillis()); + } + return intent; + } + + private static void startForegroundServiceSafely(Context context, Intent sourceIntent) { + try { + Intent intent = createPushServiceIntent(context, sourceIntent); + ContextCompat.startForegroundService(context, intent); + PushControllerUtils.registerLiveReceiver(context); + } catch (Throwable e) { + logger.e("Failed to start XMPushService as foreground", e); + } + } + + private static void startServiceSafely(Context context, Intent sourceIntent) { + try { + Intent intent = createPushServiceIntent(context, sourceIntent); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + try { + context.startService(intent); + } catch (Throwable e) { + logger.w("startService not allowed in background, falling back safely", e); + } + } else { + context.startService(intent); + } + PushControllerUtils.registerLiveReceiver(context); + } catch (Throwable e) { + logger.e("Failed to start XMPushService", e); + } + } +} diff --git a/push/src/main/java/com/xiaomi/xmsf/push/control/PushServiceStartPolicy.java b/push/src/main/java/com/xiaomi/xmsf/push/control/PushServiceStartPolicy.java new file mode 100644 index 000000000..72e50d4fa --- /dev/null +++ b/push/src/main/java/com/xiaomi/xmsf/push/control/PushServiceStartPolicy.java @@ -0,0 +1,36 @@ +package com.xiaomi.xmsf.push.control; + +/** + * Pure policy class to evaluate push service starting decisions without Android UI dependencies. + */ +public class PushServiceStartPolicy { + + public enum Action { + SKIP, + START_SERVICE, + START_FOREGROUND + } + + public static Action evaluate( + boolean isMasterEnabled, + boolean isServiceRunning, + boolean isUserInitiated, + boolean isPersistentForegroundEnabled, + boolean isPlatformAllowed) { + if (!isMasterEnabled) { + return Action.SKIP; + } + if (isServiceRunning) { + return Action.START_SERVICE; + } + if (isUserInitiated) { + return Action.START_SERVICE; + } + if (!isPlatformAllowed) { + return Action.SKIP; + } + return isPersistentForegroundEnabled + ? Action.START_FOREGROUND + : Action.START_SERVICE; + } +} diff --git a/push/src/main/java/com/xiaomi/xmsf/push/control/RegistrationRetryCoordinator.java b/push/src/main/java/com/xiaomi/xmsf/push/control/RegistrationRetryCoordinator.java new file mode 100644 index 000000000..bb22bc0dc --- /dev/null +++ b/push/src/main/java/com/xiaomi/xmsf/push/control/RegistrationRetryCoordinator.java @@ -0,0 +1,117 @@ +package com.xiaomi.xmsf.push.control; + +/** + * Owns the single delayed registration retry for this process. + * + *

The generation token prevents a retry which was already running during disable from + * scheduling itself again after a later re-enable.

+ */ +final class RegistrationRetryCoordinator { + interface TaskFactory { + Runnable create(long generation); + } + + interface Scheduler { + boolean postDelayed(Runnable task, long delayMs); + + void removeCallbacks(Runnable task); + } + + private final Scheduler scheduler; + /* + * Push is enabled by default in preferences. Starting enabled also makes retries requested + * by alternate process/service startup paths reliable before setServiceEnable(true) has had + * a chance to run. An explicit disable still invalidates the generation immediately. + */ + private boolean enabled = true; + private long generation = 1L; + private Runnable pending; + + RegistrationRetryCoordinator(Scheduler scheduler) { + this.scheduler = scheduler; + } + + synchronized void enable() { + if (!enabled) { + enabled = true; + generation++; + } + } + + synchronized boolean isEnabled() { + return enabled; + } + + synchronized void disable() { + if (enabled) { + enabled = false; + generation++; + } + if (pending != null) { + scheduler.removeCallbacks(pending); + pending = null; + } + } + + synchronized boolean schedule(long delayMs, TaskFactory taskFactory) { + return schedule(delayMs, generation, taskFactory); + } + + synchronized boolean schedule(long delayMs, long expectedGeneration, + TaskFactory taskFactory) { + if (!enabled || generation != expectedGeneration || pending != null) { + return false; + } + Runnable task = taskFactory.create(generation); + if (task == null) { + return false; + } + pending = task; + if (!scheduler.postDelayed(task, delayMs)) { + pending = null; + scheduler.removeCallbacks(task); + return false; + } + return true; + } + + synchronized boolean begin(Runnable task, long taskGeneration) { + if (pending != task) { + return false; + } + pending = null; + return enabled && generation == taskGeneration; + } + + /** + * Runs a registration side effect only while this retry generation is still current. + * + *

The action intentionally executes while holding the coordinator monitor. This closes + * the otherwise unavoidable check-then-act race with {@link #disable()}: if disable wins, + * the action is skipped; if the action already started, disable waits and the caller's + * unregister operation happens afterwards.

+ */ + synchronized boolean runIfActive(long taskGeneration, Runnable action) { + if (!enabled || generation != taskGeneration) { + return false; + } + action.run(); + return true; + } + + /** Applies the same ordering guarantee to the initial registration job. */ + synchronized boolean runIfEnabled(Runnable action) { + if (!enabled) { + return false; + } + action.run(); + return true; + } + + synchronized void cancelPending() { + if (pending != null) { + scheduler.removeCallbacks(pending); + pending = null; + } + } +} diff --git a/push/src/main/java/com/xiaomi/xmsf/push/control/StartupWorkPolicy.java b/push/src/main/java/com/xiaomi/xmsf/push/control/StartupWorkPolicy.java new file mode 100644 index 000000000..4234c0129 --- /dev/null +++ b/push/src/main/java/com/xiaomi/xmsf/push/control/StartupWorkPolicy.java @@ -0,0 +1,23 @@ +package com.xiaomi.xmsf.push.control; + +/** Pure policy for process-start background work and its elapsed-time throttle. */ +public final class StartupWorkPolicy { + private StartupWorkPolicy() { + } + + public static boolean shouldRunAppStartup( + boolean mainProcess, boolean masterEnabled) { + return mainProcess && masterEnabled; + } + + public static boolean shouldRunThrottled( + long previousElapsedRealtime, + long nowElapsedRealtime, + long minimumIntervalMs) { + if (previousElapsedRealtime == 0L) { + return true; + } + long elapsed = nowElapsedRealtime - previousElapsedRealtime; + return elapsed < 0L || elapsed >= minimumIntervalMs; + } +} diff --git a/push/src/main/java/com/xiaomi/xmsf/push/notification/FocusNotificationReplay.java b/push/src/main/java/com/xiaomi/xmsf/push/notification/FocusNotificationReplay.java new file mode 100644 index 000000000..c0c4d84c7 --- /dev/null +++ b/push/src/main/java/com/xiaomi/xmsf/push/notification/FocusNotificationReplay.java @@ -0,0 +1,123 @@ +package com.xiaomi.xmsf.push.notification; + +import androidx.annotation.Nullable; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; + +/** + * Refreshes the public, string-based part of Xiaomi's focus payload for a + * user-requested replay. + * + *

Focus notifications carry an expiry sequence. Replaying the bytes from + * an old event verbatim makes HyperOS correctly discard the notification as + * stale, even though the ordinary Android title/body are still available. + * This helper changes only replay timestamps; business content, URLs and + * private Parcelable fields are never synthesized.

+ */ +public final class FocusNotificationReplay { + private static final String FOCUS_PARAM = "miui.focus.param"; + private static final String FOCUS_PARAM_CUSTOM = "miui.focus.param.custom"; + private static final String[] TIMESTAMP_KEYS = { + "t_fe_s", "t_fe", "t_mt_s", "t_mt", "t_q_s", "t_q", "fe_ts", "__m_ts" + }; + + private FocusNotificationReplay() { + } + + /** + * Return a copy of extras with known server timestamps refreshed. The + * input map is never mutated, which keeps the stored event immutable. + */ + public static Map refreshExtras( + @Nullable Map extras, long timestampMillis) { + if (extras == null || extras.isEmpty()) { + return extras; + } + Map refreshed = new HashMap<>(extras); + refreshParameter(refreshed, FOCUS_PARAM, timestampMillis); + refreshParameter(refreshed, FOCUS_PARAM_CUSTOM, timestampMillis); + String timestamp = Long.toString(timestampMillis); + for (String key : TIMESTAMP_KEYS) { + if (refreshed.containsKey(key)) { + refreshed.put(key, timestamp); + } + } + return refreshed; + } + + @Nullable + private static String refreshParameter( + Map extras, String key, long timestampMillis) { + String parameter = extras.get(key); + if (!FocusNotificationSafety.isWellFormedParameter(parameter)) { + return parameter; + } + try { + JsonElement parsed = JsonParser.parseString(parameter); + if (!parsed.isJsonObject()) { + return parameter; + } + JsonObject root = parsed.getAsJsonObject(); + // SystemUI uses the top-level sequence to reject expired focus + // records. Some HyperOS templates duplicate it inside param_v2. + // Keep the JSON scalar type supplied by the sender; clients may use + // a string at the top level and a number in the nested protocol. + // A few SystemUI builds read these fields with a strict accessor. + replaceSequence(root, timestampMillis); + JsonElement paramV2 = root.get("param_v2"); + if (paramV2 != null && paramV2.isJsonObject()) { + JsonObject paramV2Object = paramV2.getAsJsonObject(); + replaceSequence(paramV2Object, timestampMillis); + // A replay is an explicit user action. The original sender's + // permission gate describes its live delivery context and can + // make HyperOS hide the entire focus view for a locally + // replayed third-party event. Keep the focus payload visible; + // the normal Android notification remains the fallback. + disablePermissionFilter(paramV2Object); + } + // A few payload producers put the permission gate at the root; + // handle that form as well without inventing a new protocol field. + disablePermissionFilter(root); + String refreshed = root.toString(); + // Never turn a valid payload into an oversized one. The normal + // notification path will still deliver the original value. + if (refreshed.getBytes(StandardCharsets.UTF_8).length + > FocusNotificationSafety.MAX_PARAMETER_BYTES) { + return parameter; + } + extras.put(key, refreshed); + return refreshed; + } catch (Throwable ignored) { + return parameter; + } + } + + private static void replaceSequence(JsonObject object, long timestampMillis) { + JsonElement existing = object.get("sequence"); + if (existing == null || !existing.isJsonPrimitive()) { + return; + } + try { + if (existing.getAsJsonPrimitive().isString()) { + object.addProperty("sequence", Long.toString(timestampMillis)); + } else { + object.addProperty("sequence", timestampMillis); + } + } catch (Throwable ignored) { + // Keep the original scalar if an unusual Gson primitive cannot be + // inspected; replay must never invalidate the stored payload. + } + } + + private static void disablePermissionFilter(JsonObject object) { + if (object.has("filterWhenNoPermission")) { + object.addProperty("filterWhenNoPermission", false); + } + } +} diff --git a/push/src/main/java/com/xiaomi/xmsf/push/notification/FocusNotificationSafety.java b/push/src/main/java/com/xiaomi/xmsf/push/notification/FocusNotificationSafety.java new file mode 100644 index 000000000..055eda58a --- /dev/null +++ b/push/src/main/java/com/xiaomi/xmsf/push/notification/FocusNotificationSafety.java @@ -0,0 +1,614 @@ +package com.xiaomi.xmsf.push.notification; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import java.nio.charset.StandardCharsets; + +/** + * Pure-Java safety policy for Xiaomi focus-notification enhancements. + * + *

The private {@code miui.focus.*} protocol is always optional. This class keeps + * the readable Android notification and the delivery identity independent from + * that optional payload so the policy can be covered by local JVM tests.

+ */ +public final class FocusNotificationSafety { + public static final String FOCUS_EXTRA_PREFIX = "miui.focus."; + /** + * Well-known image alias used by Xiaomi focus templates for the target + * application's launcher icon. The alias is referenced from nested + * {@code param_v2} objects rather than necessarily being present as a + * top-level PushMetaInfo extra. + */ + public static final String FOCUS_APP_ICON_PICTURE = "miui.focus.pic_app_icon"; + public static final int MAX_PARAMETER_BYTES = 3_072; + public static final long IMAGE_ENRICHMENT_BUDGET_MILLIS = 700L; + public static final int PORTABLE_PROGRESS_MAX = 100; + /** First observed progress value after a delivery merchant accepts an order. */ + public static final int PORTABLE_MERCHANT_STAGE_PROGRESS = 10; + /** First observed progress value after the courier starts the delivery leg. */ + public static final int PORTABLE_COURIER_STAGE_PROGRESS = 75; + /** Progress value at which the delivery timeline is complete. */ + public static final int PORTABLE_DELIVERED_STAGE_PROGRESS = 100; + + private static final int MAX_FALLBACK_TITLE_CODE_POINTS = 160; + private static final int MAX_FALLBACK_BODY_CODE_POINTS = 1_024; + private static final int MAX_FALLBACK_URL_CODE_POINTS = 2_048; + private static final int MAX_SEQUENCE_CODE_POINTS = 128; + private static final int MAX_SCENE_CODE_POINTS = 64; + private static final String FOOD_DELIVERY_SCENE = "foodDelivery"; + private static final String FOOD_DELIVERY_BUSINESS = "food_delivery"; + private static final String DEFAULT_TITLE = "MiPush notification"; + private static final String DEFAULT_BODY = "New notification"; + private static final String FOCUS_GROUP_MARKER = "#focus#"; + + private FocusNotificationSafety() { + } + + /** + * Preserve normal title/body values and fill only missing fields from a bounded + * focus JSON object. Invalid and oversized JSON is ignored safely. + */ + public static ResolvedContent resolveReadableContent( + String title, + String body, + String focusParameter, + String fallbackTitle, + String fallbackBody) { + PortableFocusData portable = parsePortableFocusData(focusParameter); + + String resolvedTitle = hasText(title) + ? title + : firstText(portable.title(), portable.body(), + sanitizeFallback(fallbackTitle, MAX_FALLBACK_TITLE_CODE_POINTS), + DEFAULT_TITLE); + String resolvedBody = hasText(body) + ? body + : firstText(portable.body(), portable.title(), + sanitizeFallback(fallbackBody, MAX_FALLBACK_BODY_CODE_POINTS), + DEFAULT_BODY); + return new ResolvedContent(resolvedTitle, resolvedBody); + } + + /** + * Parse the public scalar fields that have a direct, safe Android fallback. + * The original JSON remains untouched for Xiaomi SystemUI, including all + * unknown fields and picture aliases. Invalid input produces an empty value + * so optional focus metadata can never suppress the ordinary notification. + */ + public static PortableFocusData parsePortableFocusData(String focusParameter) { + if (!isParameterWithinLimit(focusParameter)) { + return PortableFocusData.EMPTY; + } + try { + JsonElement parsed = JsonParser.parseString(focusParameter); + if (parsed == null || !parsed.isJsonObject()) { + return PortableFocusData.EMPTY; + } + + JsonObject root = parsed.getAsJsonObject(); + JsonObject paramV2 = object(root, "param_v2"); + JsonObject baseInfo = object(paramV2, "baseInfo"); + JsonObject progressInfo = object(paramV2, "progressInfo"); + + String scene = firstString(root, MAX_SCENE_CODE_POINTS, "scene"); + if (!hasText(scene)) { + scene = firstString(paramV2, MAX_SCENE_CODE_POINTS, "scene"); + } + String business = firstString(paramV2, MAX_SCENE_CODE_POINTS, "business"); + if (!hasText(business)) { + business = firstString(root, MAX_SCENE_CODE_POINTS, "business"); + } + + String title = firstString(root, MAX_FALLBACK_TITLE_CODE_POINTS, + "title", "ticker"); + if (!hasText(title)) { + title = firstString(baseInfo, MAX_FALLBACK_TITLE_CODE_POINTS, + "title"); + } + if (!hasText(title)) { + title = firstString(paramV2, MAX_FALLBACK_TITLE_CODE_POINTS, + "aodTitle"); + } + + String body = firstString(root, MAX_FALLBACK_BODY_CODE_POINTS, + "content", "description"); + if (!hasText(body)) { + body = firstString(baseInfo, MAX_FALLBACK_BODY_CODE_POINTS, + "content", "description", "subContent"); + } + + String url = firstString(root, MAX_FALLBACK_URL_CODE_POINTS, + "url", "intent_uri", "web_uri"); + if (!hasText(url)) { + url = firstString(paramV2, MAX_FALLBACK_URL_CODE_POINTS, + "url", "intent_uri", "web_uri"); + } + + int progress = firstNonNegativeInt(root, PORTABLE_PROGRESS_MAX, + "progress"); + if (progress < 0) { + progress = firstNonNegativeInt(progressInfo, PORTABLE_PROGRESS_MAX, + "progress"); + } + + int progressCount = firstNonNegativeInt(root, PORTABLE_PROGRESS_MAX, + "progressCount"); + if (progressCount < 0) { + progressCount = firstNonNegativeInt(paramV2, PORTABLE_PROGRESS_MAX, + "progressCount"); + } + + Boolean updatable = firstBoolean(root, "updatable"); + if (updatable == null) { + updatable = firstBoolean(paramV2, "updatable"); + } + + String sequence = firstScalarString(root, MAX_SEQUENCE_CODE_POINTS, + "sequence"); + if (!hasText(sequence)) { + sequence = firstScalarString(paramV2, MAX_SEQUENCE_CODE_POINTS, + "sequence"); + } + + // Xiaomi's delivery templates expose the accent in more than one + // documented location. Keep the value as a bounded string here; + // the Android notification renderer validates it before use. + String accentColor = firstString(progressInfo, 32, + "colorProgress", "colorProgressEnd"); + if (!hasText(accentColor)) { + JsonObject paramIsland = object(paramV2, "param_island"); + accentColor = firstString(paramIsland, 32, "highlightColor"); + } + if (!hasText(accentColor)) { + accentColor = firstString(baseInfo, 32, "colorSubTitle"); + } + if (!hasText(accentColor)) { + accentColor = firstString(root, 32, "colorDesc", "colorDescDark"); + } + + return new PortableFocusData(title, body, url, sequence, scene, business, + progress, progressCount, Boolean.TRUE.equals(updatable), accentColor); + } catch (Throwable ignored) { + return PortableFocusData.EMPTY; + } + } + + public static boolean isParameterWithinLimit(String parameter) { + if (parameter == null || parameter.length() > MAX_PARAMETER_BYTES) { + return false; + } + return parameter.getBytes(StandardCharsets.UTF_8).length <= MAX_PARAMETER_BYTES; + } + + /** + * Returns whether a bounded focus parameter is a JSON object that the + * SystemUI focus renderer can consume. This is intentionally a small + * syntactic check; fields unknown to this bridge are still forwarded. + * + *

A malformed parameter must never make the ordinary Android + * notification disappear. Callers can use this predicate to skip the + * optional focus extras while retaining the URL/text fallback.

+ */ + public static boolean isWellFormedParameter(String parameter) { + if (!isParameterWithinLimit(parameter)) { + return false; + } + try { + JsonElement root = JsonParser.parseString(parameter); + return root != null && root.isJsonObject(); + } catch (Throwable ignored) { + return false; + } + } + + /** + * Returns whether a bounded focus JSON payload references a picture alias. + * + *

HyperOS commonly stores the alias in {@code param_v2} several levels + * below the root (and some producers use an array). Looking only at the + * top-level picture map therefore misses the application-icon request. + * This traversal is deliberately bounded so malformed/deep payloads cannot + * affect ordinary notification delivery.

+ */ + public static boolean referencesPictureAlias(String parameter, String alias) { + if (alias == null || alias.isEmpty() || !isWellFormedParameter(parameter)) { + return false; + } + try { + return referencesPictureAlias(JsonParser.parseString(parameter), alias, 0); + } catch (Throwable ignored) { + return false; + } + } + + private static boolean referencesPictureAlias( + JsonElement element, String alias, int depth) { + // A focus parameter is capped at 3 KiB, but a malicious sender can + // still construct thousands of nested arrays. Keep this optional + // enhancement cheap and fail closed at a modest depth. + if (element == null || depth > 64) { + return false; + } + if (element.isJsonPrimitive()) { + try { + return element.getAsJsonPrimitive().isString() + && alias.equals(element.getAsString()); + } catch (Throwable ignored) { + return false; + } + } + if (element.isJsonArray()) { + for (JsonElement child : element.getAsJsonArray()) { + if (referencesPictureAlias(child, alias, depth + 1)) { + return true; + } + } + return false; + } + if (element.isJsonObject()) { + for (java.util.Map.Entry entry + : element.getAsJsonObject().entrySet()) { + if (alias.equals(entry.getKey()) + || referencesPictureAlias(entry.getValue(), alias, depth + 1)) { + return true; + } + } + } + return false; + } + + public static boolean isFocusExtraKey(String key) { + return key != null && key.startsWith(FOCUS_EXTRA_PREFIX); + } + + public static String stableFocusGroup(String packageName) { + String prefix = hasText(packageName) ? packageName : "mipush"; + return prefix + "_" + FOCUS_GROUP_MARKER; + } + + /** + * Focus messages without an explicit, official group must be isolated from + * the SDK's historical package-wide default group. The caller still owns + * any official group prefixing and pass-through semantics; this method only + * answers the ambiguity at the default boundary. + */ + public static boolean shouldIsolateFocusGroup( + String explicitGroup, boolean hasFocusPayload) { + return hasFocusPayload && !hasText(explicitGroup); + } + + /** + * Try the focus-enhanced delivery once. If it throws, call the same delivery + * exactly once more with the same package/tag/id and focus disabled. + */ + public static T deliverWithSingleFallback( + String packageName, + String tag, + int notificationId, + boolean attemptFocus, + Delivery delivery) { + if (!attemptFocus) { + return invoke(delivery, packageName, tag, notificationId, false, null); + } + try { + return delivery.deliver(packageName, tag, notificationId, true, null); + } catch (Throwable focusFailure) { + try { + return delivery.deliver(packageName, tag, notificationId, + false, focusFailure); + } catch (Throwable fallbackFailure) { + if (fallbackFailure != focusFailure) { + try { + fallbackFailure.addSuppressed(focusFailure); + } catch (Throwable ignored) { + } + } + return rethrow(fallbackFailure); + } + } + } + + private static T invoke( + Delivery delivery, + String packageName, + String tag, + int notificationId, + boolean includeFocus, + Throwable focusFailure) { + try { + return delivery.deliver(packageName, tag, notificationId, + includeFocus, focusFailure); + } catch (Throwable failure) { + return rethrow(failure); + } + } + + private static T rethrow(Throwable failure) { + if (failure instanceof RuntimeException) { + throw (RuntimeException) failure; + } + if (failure instanceof Error) { + throw (Error) failure; + } + throw new IllegalStateException("Notification delivery failed", failure); + } + + private static String boundedString(JsonObject object, String name, int maxCodePoints) { + if (object == null) { + return null; + } + JsonElement value = object.get(name); + if (value == null || !value.isJsonPrimitive() + || !value.getAsJsonPrimitive().isString()) { + return null; + } + return sanitizeFallback(value.getAsString(), maxCodePoints); + } + + private static JsonObject object(JsonObject parent, String name) { + if (parent == null || name == null) { + return null; + } + JsonElement value = parent.get(name); + return value != null && value.isJsonObject() ? value.getAsJsonObject() : null; + } + + private static String firstString( + JsonObject object, int maxCodePoints, String... names) { + if (object == null || names == null) { + return null; + } + for (String name : names) { + String value = boundedString(object, name, maxCodePoints); + if (hasText(value)) { + return value; + } + } + return null; + } + + private static String firstScalarString( + JsonObject object, int maxCodePoints, String... names) { + if (object == null || names == null) { + return null; + } + for (String name : names) { + JsonElement value = object.get(name); + if (value == null || !value.isJsonPrimitive()) { + continue; + } + try { + String result = sanitizeFallback(value.getAsString(), maxCodePoints); + if (hasText(result)) { + return result; + } + } catch (Throwable ignored) { + // Try the next documented alias. + } + } + return null; + } + + private static int firstNonNegativeInt( + JsonObject object, int maximum, String... names) { + if (object == null || names == null) { + return -1; + } + for (String name : names) { + JsonElement value = object.get(name); + if (value == null || !value.isJsonPrimitive()) { + continue; + } + try { + int parsed = value.getAsInt(); + if (parsed >= 0) { + return Math.min(maximum, parsed); + } + } catch (Throwable ignored) { + // Try the next documented alias. + } + } + return -1; + } + + private static Boolean firstBoolean(JsonObject object, String... names) { + if (object == null || names == null) { + return null; + } + for (String name : names) { + JsonElement value = object.get(name); + if (value == null || !value.isJsonPrimitive()) { + continue; + } + try { + String raw = value.getAsString(); + if ("true".equalsIgnoreCase(raw)) { + return Boolean.TRUE; + } + if ("false".equalsIgnoreCase(raw)) { + return Boolean.FALSE; + } + } catch (Throwable ignored) { + // Try the next documented alias. + } + } + return null; + } + + private static String sanitizeFallback(String value, int maxCodePoints) { + if (!hasText(value)) { + return null; + } + StringBuilder clean = new StringBuilder(Math.min(value.length(), maxCodePoints)); + for (int offset = 0; offset < value.length(); ) { + int codePoint = value.codePointAt(offset); + offset += Character.charCount(codePoint); + if (!Character.isISOControl(codePoint) + || codePoint == '\n' || codePoint == '\t') { + clean.appendCodePoint(codePoint); + } + } + String result = clean.toString().trim(); + if (!hasText(result)) { + return null; + } + int count = result.codePointCount(0, result.length()); + if (count <= maxCodePoints) { + return result; + } + int end = result.offsetByCodePoints(0, maxCodePoints); + return result.substring(0, end); + } + + private static String firstText(String... candidates) { + for (String candidate : candidates) { + if (hasText(candidate)) { + return candidate; + } + } + throw new IllegalStateException("At least one safe fallback must be present"); + } + + private static boolean hasText(String value) { + return value != null && !value.trim().isEmpty(); + } + + @FunctionalInterface + public interface Delivery { + T deliver(String packageName, String tag, int notificationId, + boolean includeFocusExtras, Throwable focusFailure) throws Throwable; + } + + public static final class ResolvedContent { + private final String title; + private final String body; + + private ResolvedContent(String title, String body) { + this.title = title; + this.body = body; + } + + public String title() { + return title; + } + + public String body() { + return body; + } + } + + public static final class PortableFocusData { + private static final PortableFocusData EMPTY = new PortableFocusData( + null, null, null, null, null, null, -1, -1, false, null); + + private final String title; + private final String body; + private final String url; + private final String sequence; + private final String scene; + private final String business; + private final int progress; + private final int progressCount; + private final boolean updatable; + private final String accentColor; + + private PortableFocusData( + String title, + String body, + String url, + String sequence, + String scene, + String business, + int progress, + int progressCount, + boolean updatable, + String accentColor) { + this.title = title; + this.body = body; + this.url = url; + this.sequence = sequence; + this.scene = scene; + this.business = business; + this.progress = progress; + this.progressCount = progressCount; + this.updatable = updatable; + this.accentColor = accentColor; + } + + public String title() { + return title; + } + + public String body() { + return body; + } + + public String url() { + return url; + } + + public String sequence() { + return sequence; + } + + public String scene() { + return scene; + } + + public String business() { + return business; + } + + public int progress() { + return progress; + } + + public int progressCount() { + return progressCount; + } + + public boolean updatable() { + return updatable; + } + + /** Optional sender-provided accent color for a portable timeline. */ + public String accentColor() { + return accentColor; + } + + public boolean hasProgress() { + return progress >= 0; + } + + /** + * Select the delivery renderer by protocol identity, never by sender package. + * The business alias is accepted only when scene is absent so another explicit + * scene cannot accidentally inherit the food-delivery presentation. + */ + public boolean isFoodDeliveryTimeline() { + return FOOD_DELIVERY_SCENE.equals(scene) + || (!hasText(scene) && FOOD_DELIVERY_BUSINESS.equals(business)); + } + + /** Maps observed delivery progress to one of the three portable stages. */ + public int stageIndex() { + if (!hasProgress() || progress < PORTABLE_MERCHANT_STAGE_PROGRESS) { + return -1; + } + if (progress < PORTABLE_COURIER_STAGE_PROGRESS) { + return 0; + } + if (progress < PORTABLE_DELIVERED_STAGE_PROGRESS) { + return 1; + } + return 2; + } + + /** Discrete timeline position used to draw the three-node indicator. */ + public int timelineProgress() { + return Math.max(0, stageIndex()) * 50; + } + } +} diff --git a/push/src/main/java/com/xiaomi/xmsf/push/notification/FocusProtocolSupportCache.java b/push/src/main/java/com/xiaomi/xmsf/push/notification/FocusProtocolSupportCache.java new file mode 100644 index 000000000..64b2f3184 --- /dev/null +++ b/push/src/main/java/com/xiaomi/xmsf/push/notification/FocusProtocolSupportCache.java @@ -0,0 +1,51 @@ +package com.xiaomi.xmsf.push.notification; + +import java.util.function.BooleanSupplier; + +/** + * Small process-local cache for HyperOS' focus protocol capability setting. + * The value changes very rarely, but a bounded lifetime lets SystemUI updates + * take effect without restarting this process. + */ +final class FocusProtocolSupportCache { + static final long NO_CACHED_VALUE = Long.MIN_VALUE; + + private final long ttlMillis; + private volatile long cachedAtElapsedRealtime = NO_CACHED_VALUE; + private volatile boolean cachedValue; + + FocusProtocolSupportCache(long ttlMillis) { + if (ttlMillis <= 0L) { + throw new IllegalArgumentException("ttlMillis must be positive"); + } + this.ttlMillis = ttlMillis; + } + + boolean get(long nowElapsedRealtime, BooleanSupplier resolver) { + long cachedAt = cachedAtElapsedRealtime; + if (isFresh(cachedAt, nowElapsedRealtime, ttlMillis)) { + return cachedValue; + } + synchronized (this) { + cachedAt = cachedAtElapsedRealtime; + if (isFresh(cachedAt, nowElapsedRealtime, ttlMillis)) { + return cachedValue; + } + boolean resolved = resolver.getAsBoolean(); + cachedValue = resolved; + cachedAtElapsedRealtime = nowElapsedRealtime; + return resolved; + } + } + + static boolean isFresh( + long cachedAtElapsedRealtime, + long nowElapsedRealtime, + long ttlMillis) { + if (cachedAtElapsedRealtime == NO_CACHED_VALUE) { + return false; + } + long elapsed = nowElapsedRealtime - cachedAtElapsedRealtime; + return elapsed >= 0L && elapsed < ttlMillis; + } +} diff --git a/push/src/main/java/com/xiaomi/xmsf/push/notification/NotificationChannelManager.java b/push/src/main/java/com/xiaomi/xmsf/push/notification/NotificationChannelManager.java index 83d58cd4e..129bcca1e 100644 --- a/push/src/main/java/com/xiaomi/xmsf/push/notification/NotificationChannelManager.java +++ b/push/src/main/java/com/xiaomi/xmsf/push/notification/NotificationChannelManager.java @@ -1,5 +1,9 @@ package com.xiaomi.xmsf.push.notification; +import static top.trumeet.common.utils.NotificationAlertUtils.NOTIFY_TYPE_LIGHTS; +import static top.trumeet.common.utils.NotificationAlertUtils.NOTIFY_TYPE_SOUND; +import static top.trumeet.common.utils.NotificationAlertUtils.NOTIFY_TYPE_VIBRATE; +import static top.trumeet.common.utils.NotificationAlertUtils.usesPackageResourceSound; import static top.trumeet.common.utils.NotificationUtils.getChannelIdByPkg; import static top.trumeet.common.utils.NotificationUtils.getGroupIdByPkg; @@ -21,6 +25,7 @@ import com.nihility.XMPushUtils; import com.nihility.notification.NotificationManagerEx; import com.xiaomi.xmpush.thrift.PushMetaInfo; +import com.xiaomi.xmsf.R; import java.util.Arrays; @@ -28,6 +33,13 @@ public class NotificationChannelManager { + /** Dedicated channel for the settings-page replay actions. */ + public static final String DEBUG_CHANNEL_ID = "mipush_debug_test_v2"; + /** Channel suffix for portable focus notifications that should heads-up. */ + private static final String PORTABLE_FOCUS_CHANNEL_SUFFIX = "_mipush_focus_v1"; + static final int PORTABLE_FOCUS_CHANNEL_IMPORTANCE = + NotificationManager.IMPORTANCE_HIGH; + public static NotificationManagerEx getNotificationManagerEx() { return NotificationManagerEx.INSTANCE; } @@ -43,15 +55,26 @@ private static NotificationChannel createChannelWithPackage(@NonNull PushMetaInf CustomConfiguration configuration = XMPushUtils.getConfiguration(metaInfo); String channelName = configuration.channelName("未分类"); String channelDescription = configuration.channelDescription(null); - String sound = configuration.soundUrl(null); + String sound = configuration.soundUri(null); NotificationChannel channel = null; if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { - channel = new NotificationChannel(getChannelId(metaInfo, packageName), channelName, NotificationManager.IMPORTANCE_DEFAULT); + int importance = configuration.channelImportance( + NotificationManager.IMPORTANCE_DEFAULT); + int notifyType = metaInfo.getNotifyType(); + channel = new NotificationChannel( + getChannelId(metaInfo, packageName), channelName, importance); channel.setDescription(channelDescription); - if (sound != null) { + channel.enableVibration( + (notifyType & NOTIFY_TYPE_VIBRATE) != 0); + channel.enableLights( + (notifyType & NOTIFY_TYPE_LIGHTS) != 0); + if ((notifyType & NOTIFY_TYPE_SOUND) == 0) { + channel.setSound(null, null); + } else if (usesPackageResourceSound(notifyType, sound, packageName)) { AudioAttributes attr = new AudioAttributes.Builder() - .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) + .setUsage(AudioAttributes.USAGE_NOTIFICATION) + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) .build(); channel.setSound(Uri.parse(sound), attr); } @@ -96,15 +119,103 @@ public static NotificationChannel registerChannelIfNeeded(Context context, PushM } + /** + * Manual replay must remain observable even when a user's normal/default + * channel was muted by an earlier configuration. Keep it isolated from + * client channels and request a heads-up-capable importance once. + */ + public static void registerDebugChannelIfNeeded(Context context, String packageName) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return; + } + NotificationChannel existing = + getNotificationManagerEx().getNotificationChannel(packageName, DEBUG_CHANNEL_ID); + if (existing != null) { + return; + } + NotificationChannel channel = new NotificationChannel( + DEBUG_CHANNEL_ID, "MiPush Framework test", NotificationManager.IMPORTANCE_HIGH); + channel.setDescription("Manual notification replay"); + channel.enableVibration(true); + channel.enableLights(true); + getNotificationManagerEx().createNotificationChannels( + packageName, Arrays.asList(channel)); + } + + /** + * Create the user-visible high-importance channel used by the portable + * three-stage focus renderer. A dedicated channel keeps ordinary message + * channel preferences intact while giving focus updates a heads-up-capable + * default on AOSP and other non-Xiaomi SystemUI implementations. + */ + @Nullable + public static NotificationChannel ensurePortableFocusChannel( + Context context, String packageName) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O + || context == null + || TextUtils.isEmpty(packageName)) { + return null; + } + + try { + String channelId = getPortableFocusChannelId(packageName); + NotificationChannel existing = + getNotificationManagerEx().getNotificationChannel(packageName, channelId); + if (existing != null) { + return existing; + } + + CharSequence appName = null; + try { + appName = Global.ApplicationNameCache().getAppName(context, packageName); + } catch (Throwable ignored) { + } + if (TextUtils.isEmpty(appName)) { + appName = packageName; + } + + NotificationChannel channel = new NotificationChannel( + channelId, + context.getString(R.string.notification_focus_channel_name), + PORTABLE_FOCUS_CHANNEL_IMPORTANCE); + channel.setDescription( + context.getString(R.string.notification_focus_channel_description)); + channel.enableVibration(true); + channel.enableLights(true); + + try { + NotificationChannelGroup group = createGroupWithPackage(packageName, appName); + getNotificationManagerEx().createNotificationChannelGroups( + packageName, Arrays.asList(group)); + channel.setGroup(group.getId()); + } catch (Throwable ignored) { + // A group is cosmetic. Channel creation remains authoritative. + } + + getNotificationManagerEx().createNotificationChannels( + packageName, Arrays.asList(channel)); + return getNotificationManagerEx().getNotificationChannel(packageName, channelId); + } catch (Throwable ignored) { + // The caller preserves its original channel when package-attributed + // channel creation is unavailable on this ROM. + return null; + } + } + + public static String getPortableFocusChannelId(String packageName) { + return getChannelIdByPkg(packageName) + PORTABLE_FOCUS_CHANNEL_SUFFIX; + } + private static NotificationChannel createNotificationChannel(PushMetaInfo metaInfo, String packageName, CharSequence appName) { NotificationChannelGroup notificationChannelGroup = createGroupWithPackage(packageName, appName); getNotificationManagerEx().createNotificationChannelGroups( packageName, Arrays.asList(notificationChannelGroup)); NotificationChannel notificationChannel = createChannelWithPackage(metaInfo, packageName); - if (notificationChannel != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - notificationChannel.setGroup(notificationChannelGroup.getId()); + if (notificationChannel == null) { + return null; } + notificationChannel.setGroup(notificationChannelGroup.getId()); getNotificationManagerEx().createNotificationChannels( packageName, Arrays.asList(notificationChannel)); diff --git a/push/src/main/java/com/xiaomi/xmsf/push/notification/NotificationController.java b/push/src/main/java/com/xiaomi/xmsf/push/notification/NotificationController.java index 2aacbe51a..aa9d7f87c 100644 --- a/push/src/main/java/com/xiaomi/xmsf/push/notification/NotificationController.java +++ b/push/src/main/java/com/xiaomi/xmsf/push/notification/NotificationController.java @@ -2,20 +2,30 @@ import static com.xiaomi.push.service.MyMIPushNotificationHelper.getNotificationTag; import static com.xiaomi.push.service.MyNotificationIconHelper.KiB; +import static top.trumeet.common.utils.NotificationAlertUtils.NOTIFY_TYPE_SOUND; +import static top.trumeet.common.utils.NotificationAlertUtils.usesPackageResourceSound; import android.annotation.TargetApi; import android.app.Notification; import android.app.PendingIntent; import android.content.Context; +import android.content.res.Configuration; import android.content.Intent; import android.content.pm.PackageManager; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.graphics.Color; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.Icon; +import android.net.Uri; import android.os.Build; import android.os.Bundle; +import android.os.SystemClock; +import android.provider.Settings; import android.service.notification.StatusBarNotification; import android.text.TextUtils; +import android.util.LruCache; +import android.widget.RemoteViews; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -30,6 +40,7 @@ import com.nihility.XMPushUtils; import com.nihility.notification.NotificationManagerEx; import com.xiaomi.push.service.MyNotificationIconHelper; +import com.xiaomi.push.service.MyMIPushNotificationHelper; import com.xiaomi.xmpush.thrift.PushMetaInfo; import com.xiaomi.xmpush.thrift.XmPushActionContainer; import com.xiaomi.xmsf.R; @@ -37,8 +48,28 @@ import com.xiaomi.xmsf.push.utils.IconConfigurations; import com.xiaomi.xmsf.utils.ColorUtil; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicInteger; + import top.trumeet.common.utils.CustomConfiguration; +import top.trumeet.common.utils.DeviceFocusPolicy; import top.trumeet.common.utils.ImgUtils; +import top.trumeet.common.utils.NotificationMetadata; import top.trumeet.mipushframework.main.AdvancedSettingsPage; /** @@ -51,13 +82,49 @@ public class NotificationController { private static final String NOTIFICATION_LARGE_ICON = "mipush_notification"; private static final String NOTIFICATION_SMALL_ICON = "mipush_small_notification"; - + private static final int NOTIFICATION_LARGE_ICON_DP = 64; + private static final int MIN_NOTIFICATION_LARGE_ICON_PX = 64; + private static final int MAX_NOTIFICATION_LARGE_ICON_PX = 256; + private static final String FOCUS_PROTOCOL_SETTING = "notification_focus_protocol"; + private static final String FOCUS_PARAM = "miui.focus.param"; + private static final String FOCUS_PARAM_CUSTOM = "miui.focus.param.custom"; + private static final String FOCUS_PICTURES = "miui.focus.pics"; + private static final long FOCUS_PROTOCOL_CACHE_TTL_MILLIS = 5 * 60 * 1000L; + private static final FocusProtocolSupportCache FOCUS_PROTOCOL_SUPPORT_CACHE = + new FocusProtocolSupportCache(FOCUS_PROTOCOL_CACHE_TTL_MILLIS); + private static final AtomicInteger MOCK_NOTIFICATION_SEQUENCE = + new AtomicInteger(10_000); + // The official client permits a much longer network timeout. Holding our + // notification worker for that long can starve all push notifications, so the + // native-icon enhancement gets a small global budget while the URL payload stays. public static final String CHANNEL_WARN = "warn"; public static NotificationManagerEx getNotificationManagerEx() { return NotificationManagerEx.INSTANCE; } + /** Best-effort preflight used by the settings-page diagnostic actions. */ + public static boolean areNotificationsEnabled(Context context, String packageName) { + try { + return getNotificationManagerEx().areNotificationsEnabled(packageName); + } catch (Throwable error) { + // A failed hidden-API probe must not suppress a real delivery. + logger.w("Unable to inspect notification permission", error); + return true; + } + } + + /** + * Release process-local notification artwork when Android reports memory + * pressure. In-flight downloads are intentionally left alone: cancelling + * them could turn a valid focus notification into a missing notification. + */ + public static void trimMemory(int level) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + FocusIconApi23.trimMemory(level); + } + } + @TargetApi(Build.VERSION_CODES.N) private static void updateSummaryNotification(Context context, PushMetaInfo metaInfo, String packageName, String groupId) { @@ -76,7 +143,11 @@ private static void updateSummaryNotification(Context context, PushMetaInfo meta builder.setCategory(Notification.CATEGORY_EVENT) .setGroupSummary(true) .setGroup(groupId); - notify(context, groupId.hashCode(), packageName, builder, metaInfo); + // The summary is an implementation detail of Android notification + // grouping. It has no application focus payload of its own; processing + // the source message again here would duplicate extras and image work. + notify(context, groupId.hashCode(), packageName, getNotificationTag(packageName), + builder, metaInfo, false, false, null); } @RequiresApi(api = Build.VERSION_CODES.M) @@ -90,10 +161,16 @@ private static int getNotificationCountOfGroup(String packageName, String groupI StatusBarNotification[] activeNotifications = getNotificationManagerEx().getActiveNotifications(packageName); - + if (activeNotifications == null) { + return 0; + } int notificationCntInGroup = 0; for (StatusBarNotification statusBarNotification : activeNotifications) { - if (groupId.equals(statusBarNotification.getNotification().getGroup())) { + if (statusBarNotification != null + && statusBarNotification.getNotification() != null + && groupId.equals(statusBarNotification.getNotification().getGroup()) + && (statusBarNotification.getNotification().flags + & Notification.FLAG_GROUP_SUMMARY) == 0) { notificationCntInGroup++; } } @@ -102,19 +179,293 @@ private static int getNotificationCountOfGroup(String packageName, String groupI public static void publish(Context context, PushMetaInfo metaInfo, int notificationId, String packageName, NotificationCompat.Builder notificationBuilder) { String channelId = getExistsChannelId(context, metaInfo, packageName); - notificationBuilder.setChannelId(channelId); + // Preserve an explicit channel selected by a caller (the settings-page + // replay uses a dedicated high-importance channel). Older code always + // overwrote it with the client's derived channel, making the replay + // appear to do nothing when that channel had been muted. + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O + || hasNoExplicitChannel(notificationBuilder)) { + notificationBuilder.setChannelId(channelId); + } notificationBuilder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN); - //for VERSION < Oero - notificationBuilder.setDefaults(Notification.DEFAULT_ALL); + applyAlertBehavior(metaInfo, packageName, notificationBuilder); notificationBuilder.setPriority(Notification.PRIORITY_HIGH); - Notification notification = notify(context, notificationId, packageName, notificationBuilder, metaInfo); + boolean attemptFocus = shouldAttachFocusExtras(context, metaInfo); + FocusNotificationSafety.PortableFocusData portableFocus = + getPortableFocusData(metaInfo); + boolean usePortableFocus = !attemptFocus + && portableFocus.hasProgress() + && portableFocus.isFoodDeliveryTimeline(); + if (usePortableFocus) { + // A separate high-importance channel gives portable focus updates a + // heads-up/floating presentation without changing ordinary message + // channel preferences. + applyPortableFocusChannel(context, packageName, notificationBuilder); + } + if (!attemptFocus) { + applyPortableFocusPresentation(portableFocus, notificationBuilder); + } + if (attemptFocus || usePortableFocus) { + // The official group supplied by the client always wins. Debug and + // other direct callers otherwise get a stable focus-only group so a + // normal notification from the same app cannot fold it away. + boolean hasOfficialGroup = hasOfficialNotificationGroup(metaInfo); + try { + Notification preview = notificationBuilder.build(); + String existingGroup = preview.getGroup(); + if (!hasOfficialGroup + && (TextUtils.isEmpty(existingGroup) + || packageName.equals(existingGroup))) { + notificationBuilder.setGroup( + FocusNotificationSafety.stableFocusGroup(packageName)); + } + } catch (Throwable error) { + logger.w("Unable to inspect focus-notification group", error); + } + } + + String notificationTag = getNotificationTag(packageName); + Notification notification = FocusNotificationSafety.deliverWithSingleFallback( + packageName, + notificationTag, + notificationId, + attemptFocus, + (deliveryPackage, deliveryTag, deliveryId, includeFocusExtras, + focusFailure) -> { + if (!includeFocusExtras) { + if (focusFailure != null) { + logger.w("Focus notification failed; retrying as a standard notification", + focusFailure); + } + stripFocusNotificationExtras(notificationBuilder); + if (focusFailure != null) { + applyPortableFocusPresentation(portableFocus, notificationBuilder); + if (portableFocus.hasProgress() + && portableFocus.isFoodDeliveryTimeline()) { + applyPortableFocusChannel( + context, packageName, notificationBuilder); + } + } + } + return notify(context, deliveryId, deliveryPackage, deliveryTag, + notificationBuilder, metaInfo, true, includeFocusExtras, + portableFocus); + }); updateSummaryNotification(context, metaInfo, packageName, notification.getGroup()); } + /** + * Represent Xiaomi progress metadata with public Android APIs when the + * active SystemUI cannot render {@code miui.focus.*}. This is deliberately + * limited to progress and update alert behavior: protocol timeout and + * updatable fields do not imply an ongoing or automatically expiring + * Android notification. + */ + private static void applyPortableFocusPresentation( + FocusNotificationSafety.PortableFocusData focus, + NotificationCompat.Builder builder) { + if (focus == null || builder == null) { + return; + } + try { + if (focus.hasProgress()) { + builder.setProgress(FocusNotificationSafety.PORTABLE_PROGRESS_MAX, + focus.isFoodDeliveryTimeline() + ? focus.timelineProgress() + : focus.progress(), + false); + if (focus.isFoodDeliveryTimeline()) { + // IMPORTANCE_HIGH on the dedicated channel controls heads-up + // on Android O+; MAX priority preserves the same behavior on + // pre-channel Android and vendor fallbacks. + builder.setPriority(Notification.PRIORITY_MAX); + builder.setCategory(Notification.CATEGORY_PROGRESS); + } + } + if (focus.updatable()) { + builder.setOnlyAlertOnce(true); + } + } catch (Throwable error) { + // The portable enhancement is optional; standard delivery remains + // authoritative for malformed or unsupported focus payloads. + logger.w("Unable to apply portable focus presentation", error); + } + } + + private static boolean applyPortableFocusChannel( + Context context, + String packageName, + NotificationCompat.Builder builder) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return true; + } + if (NotificationChannelManager.ensurePortableFocusChannel(context, packageName) + == null) { + logger.w("Portable focus channel is unavailable; keeping the original channel"); + return false; + } + builder.setChannelId(NotificationChannelManager.getPortableFocusChannelId(packageName)); + return true; + } + + private static FocusNotificationSafety.PortableFocusData getPortableFocusData( + @Nullable PushMetaInfo metaInfo) { + if (metaInfo == null) { + return FocusNotificationSafety.parsePortableFocusData(null); + } + try { + String parameter = XMPushUtils.getConfiguration(metaInfo).focusParam(null); + return FocusNotificationSafety.parsePortableFocusData(parameter); + } catch (Throwable error) { + logger.w("Unable to parse portable focus payload", error); + return FocusNotificationSafety.parsePortableFocusData(null); + } + } + + /** + * Draw a compact, public-API notification view for ROMs without Xiaomi's + * private focus renderer. The view models the three delivery milestones + * observed in sender payloads and never depends on an application package + * name or a proprietary SystemUI class. + */ + private static void applyPortableFocusRemoteViews( + Context context, + NotificationCompat.Builder builder, + FocusNotificationSafety.PortableFocusData focus, + FocusNotificationSafety.ResolvedContent content) { + if (context == null || builder == null || focus == null || content == null + || !focus.hasProgress() || !focus.isFoodDeliveryTimeline()) { + return; + } + try { + RemoteViews compact = new RemoteViews( + context.getPackageName(), R.layout.notification_focus_portable_compact); + RemoteViews expanded = new RemoteViews( + context.getPackageName(), R.layout.notification_focus_portable); + compact.setTextViewText(R.id.focus_title, content.title()); + compact.setTextViewText(R.id.focus_body, content.body()); + expanded.setTextViewText(R.id.focus_title, content.title()); + expanded.setTextViewText(R.id.focus_body, content.body()); + + int accent = resolvePortableFocusAccent(focus); + boolean dark = (context.getResources().getConfiguration().uiMode + & Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES; + int secondary = dark + ? Color.argb(220, 255, 255, 255) + : Color.argb(190, 0, 0, 0); + int primary = dark ? Color.WHITE : Color.BLACK; + + compact.setTextColor(R.id.focus_title, primary); + compact.setTextColor(R.id.focus_body, secondary); + expanded.setTextColor(R.id.focus_title, primary); + expanded.setTextColor(R.id.focus_body, secondary); + + int stage = focus.stageIndex(); + String stageLabel = stage < 0 + ? context.getString(R.string.notification_focus_stage_waiting) + : stage == 0 + ? context.getString(R.string.notification_focus_stage_merchant) + : stage == 1 + ? context.getString(R.string.notification_focus_stage_courier) + : context.getString(R.string.notification_focus_stage_delivered); + compact.setTextViewText(R.id.focus_stage, stageLabel); + expanded.setTextViewText(R.id.focus_stage, stageLabel); + compact.setTextColor(R.id.focus_stage, stage < 0 ? secondary : accent); + expanded.setTextColor(R.id.focus_stage, stage < 0 ? secondary : accent); + int[] nodes = { + R.id.focus_dot_merchant, + R.id.focus_dot_courier, + R.id.focus_dot_delivered, + }; + int[] labels = { + R.id.focus_label_merchant, + R.id.focus_label_courier, + R.id.focus_label_delivered, + }; + for (int index = 0; index < nodes.length; index++) { + int nodeDrawable = stage == index + ? R.drawable.notification_focus_point_current + : stage > index + ? R.drawable.notification_focus_point_done + : R.drawable.notification_focus_point_inactive; + compact.setImageViewResource(nodes[index], nodeDrawable); + expanded.setImageViewResource(nodes[index], nodeDrawable); + int labelColor = stage == index + ? accent + : stage > index ? primary : secondary; + compact.setTextColor(labels[index], labelColor); + expanded.setTextColor(labels[index], labelColor); + } + int timelineProgress = focus.timelineProgress(); + compact.setProgressBar(R.id.focus_progress_track, + FocusNotificationSafety.PORTABLE_PROGRESS_MAX, + timelineProgress, false); + expanded.setProgressBar(R.id.focus_progress_track, + FocusNotificationSafety.PORTABLE_PROGRESS_MAX, + timelineProgress, false); + + builder.setStyle(new NotificationCompat.DecoratedCustomViewStyle()); + builder.setCustomContentView(compact); + builder.setCustomBigContentView(expanded); + // Heads-up surfaces are height constrained on Android 12+; the + // compact view still contains the three dots and track, while the + // full labels remain available after the shade is expanded. + builder.setCustomHeadsUpContentView(compact); + } catch (Throwable error) { + // A custom RemoteViews is an enhancement only. A normal text and + // progress notification remains authoritative if a vendor rejects + // one of the public RemoteViews operations. + logger.w("Unable to apply portable focus RemoteViews", error); + } + } + + private static int resolvePortableFocusAccent( + FocusNotificationSafety.PortableFocusData focus) { + String raw = focus.accentColor(); + if (!TextUtils.isEmpty(raw)) { + try { + return Color.parseColor(raw); + } catch (IllegalArgumentException ignored) { + // Fall through to the deterministic project accent. + } + } + return Color.rgb(255, 98, 0); + } + + private static boolean hasNoExplicitChannel(NotificationCompat.Builder builder) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return true; + } + try { + return TextUtils.isEmpty(builder.build().getChannelId()); + } catch (Throwable error) { + // A partially-built caller notification should still receive the + // derived channel rather than fail the entire delivery. + return true; + } + } + + private static boolean hasOfficialNotificationGroup(@Nullable PushMetaInfo metaInfo) { + if (metaInfo == null) { + return false; + } + try { + CustomConfiguration configuration = XMPushUtils.getConfiguration(metaInfo); + String configuredGroup = configuration.notificationGroup(null); + return (configuredGroup != null && !configuredGroup.trim().isEmpty()) + || metaInfo.passThrough == 1; + } catch (Throwable error) { + // If metadata cannot be inspected, preserving a caller-supplied + // group is safer than guessing that it is the SDK default. + logger.w("Unable to inspect official notification group", error); + return true; + } + } + @NonNull public static String getExistsChannelId(Context context, PushMetaInfo metaInfo, String packageName) { CustomConfiguration custom = XMPushUtils.getConfiguration(metaInfo); @@ -130,7 +481,10 @@ public static String getExistsChannelId(Context context, PushMetaInfo metaInfo, private static Notification notify( Context context, int notificationId, String packageName, - NotificationCompat.Builder notificationBuilder, PushMetaInfo metaInfo) { + String notificationTag, NotificationCompat.Builder notificationBuilder, + PushMetaInfo metaInfo, + boolean includeOfficialMetadata, boolean includeFocusExtras, + @Nullable FocusNotificationSafety.PortableFocusData portableFocus) { // Make the behavior consistent with official MIUI Bundle extras = new Bundle(); extras.putString("target_package", packageName); @@ -139,51 +493,894 @@ private static Notification notify( // Set small icon processIcon(context, packageName, notificationBuilder); - CustomConfiguration configuration = XMPushUtils.getConfiguration(metaInfo); - String iconUri = configuration.notificationLargeIconUri(null); - Bitmap largeIcon = getLargeIcon(context, metaInfo, iconUri); - if (largeIcon != null) { - notificationBuilder.setLargeIcon(largeIcon); + CustomConfiguration configuration = null; + if (includeOfficialMetadata) { + configuration = XMPushUtils.getConfiguration(metaInfo); + applyOfficialMetadata(context, packageName, notificationBuilder, configuration); + String iconUri = configuration.notificationLargeIconUri(null); + Bitmap largeIcon = getLargeIcon(context, metaInfo, iconUri); + if (largeIcon != null) { + notificationBuilder.setLargeIcon(largeIcon); + } + + String subText = configuration.subText(null); + buildExtraSubText(context, packageName, notificationBuilder, subText); + } - String subText = configuration.subText(null); - buildExtraSubText(context, packageName, notificationBuilder, subText); + // QQ QZone and other non-conversation payloads often omit the + // sender-icon metadata that the original MIUI client supplies. Keep + // the notification identifiable in that case while preserving every + // explicit icon selected by the sender or configuration. + applyLargeIconFallback(context, packageName, metaInfo, notificationBuilder); - String focusParam = configuration.focusParam(null); - if (focusParam != null) { - Bundle focusBundle = new Bundle(); - focusBundle.putString("miui.focus.param", focusParam); + FocusNotificationSafety.ResolvedContent resolvedContent = + ensureReadableStandardContent(context, packageName, notificationBuilder, + metaInfo, configuration); - Bundle picsBundle = new Bundle(); - for (String key : configuration.keys()) { - if (key.startsWith("miui.focus.pic_")) { - String url = configuration.get(key, null); - focusBundle.putString(key, url); - picsBundle.putParcelable(key, - getBitmapFromUri(context, iconUri, 200 * KiB)); - } - } - if (!picsBundle.isEmpty()) { - focusBundle.putBundle("miui.focus.pics", picsBundle); - } - notificationBuilder.addExtras(focusBundle); + if (!includeFocusExtras) { + applyPortableFocusRemoteViews( + context, notificationBuilder, portableFocus, resolvedContent); + } + + if (includeFocusExtras && configuration != null) { + addFocusNotificationExtras(context, packageName, notificationBuilder, configuration); } notificationBuilder.setAutoCancel(true); Notification notification = notificationBuilder.build(); + applyTargetPackage(context, notification, packageName); getNotificationManagerEx().notify( - packageName, getNotificationTag(packageName), notificationId, notification); + packageName, notificationTag, notificationId, notification); return notification; } + private static void applyLargeIconFallback( + Context context, + String packageName, + @Nullable PushMetaInfo metaInfo, + NotificationCompat.Builder builder) { + if (context == null || builder == null || TextUtils.isEmpty(packageName) + || hasLargeIcon(builder)) { + return; + } + + try { + CustomConfiguration configuration = metaInfo == null + ? null : XMPushUtils.getConfiguration(metaInfo); + if (configuration != null) { + String[] configuredIcons = { + configuration.notificationLargeIconUri(null), + configuration.conversationSenderIcon(null), + configuration.conversationIcon(null) + }; + for (String iconUri : configuredIcons) { + if (TextUtils.isEmpty(iconUri)) { + continue; + } + Bitmap configuredIcon = getLargeIcon(context, metaInfo, iconUri); + if (configuredIcon != null) { + builder.setLargeIcon(configuredIcon); + return; + } + } + } + + Bitmap applicationIcon = Global.IconCache().getRawIconBitmap(context, packageName); + if (applicationIcon != null && !applicationIcon.isRecycled()) { + Bitmap boundedIcon = prepareLargeIconForNotification( + context, metaInfo, applicationIcon); + if (boundedIcon != null) { + builder.setLargeIcon(boundedIcon); + } + } + } catch (Throwable error) { + // Icon decoration is optional. A missing/broken package icon must + // never turn a valid push into a failed notification delivery. + logger.w("Unable to resolve notification large-icon fallback", error); + } + } + + private static boolean hasLargeIcon(NotificationCompat.Builder builder) { + try { + Notification notification = builder.build(); + return notification.getLargeIcon() != null; + } catch (Throwable error) { + // A partially populated builder may not be buildable yet. Let the + // caller attempt the fallback; setLargeIcon remains best effort. + return false; + } + } + + private static boolean shouldAttachFocusExtras(Context context, PushMetaInfo metaInfo) { + try { + // The private miui.focus.* contract is meaningful only when the + // active ROM exposes Xiaomi's SystemUI renderer. On AOSP and other + // vendors the portable builder below remains the source of truth; + // forwarding private extras there can make vendor SystemUI choose + // an empty custom view instead of the readable fallback. + if (!usesXiaomiSystemFocusRenderer(context)) { + return false; + } + CustomConfiguration configuration = XMPushUtils.getConfiguration(metaInfo); + CustomConfiguration.FocusNotificationPayload payload = + configuration.focusNotificationPayload(); + if (!payload.isUsable()) { + return false; + } + // Keep the legacy XMSF contract: the public miui.focus.* payload is + // forwarded whenever the sender supplied it. The private protocol + // setting is only a capability hint for optional native-image + // enrichment; making it a hard gate caused focus notifications to + // silently degrade on HyperOS builds which do not expose the setting + // to third-party/system-app bridges. + // Do not hand malformed JSON to the private renderer. Valid picture + // URL fields remain independently useful and are still forwarded. + return FocusNotificationSafety.isWellFormedParameter(payload.parameter()) + || FocusNotificationSafety.isWellFormedParameter(payload.customParameter()) + || !payload.pictureUrls().isEmpty(); + } catch (Throwable error) { + logger.w("Unable to inspect focus-notification payload", error); + return false; + } + } + + private static void stripFocusNotificationExtras( + NotificationCompat.Builder notificationBuilder) { + try { + stripFocusNotificationExtras(notificationBuilder.getExtras()); + } catch (Throwable error) { + // A malformed Parcelable in a third-party payload must not prevent + // the standard retry from being attempted. + logger.w("Unable to fully strip focus-notification extras", error); + } + } + + private static void stripFocusNotificationExtras(@Nullable Bundle extras) { + if (extras == null) { + return; + } + // NotificationCompat.addExtras() flattens the focus bundle into the + // builder's top-level extras. Removing those protocol keys directly is + // sufficient and avoids traversing arbitrary third-party Bundles (which + // may be self-referential or contain unparcelable values). + for (String key : new ArrayList<>(extras.keySet())) { + try { + if (FocusNotificationSafety.isFocusExtraKey(key)) { + extras.remove(key); + } + } catch (Throwable error) { + // Keep walking the remaining keys. Bundle access can throw for + // a bad parcelable, but no focus key should block delivery. + } + } + } + + private static FocusNotificationSafety.ResolvedContent ensureReadableStandardContent( + Context context, + String packageName, + NotificationCompat.Builder notificationBuilder, + PushMetaInfo metaInfo, + @Nullable CustomConfiguration configuration) { + String existingTitle = null; + String existingBody = null; + try { + Notification preview = notificationBuilder.build(); + if (preview.extras != null) { + CharSequence title = preview.extras.getCharSequence(Notification.EXTRA_TITLE); + CharSequence body = preview.extras.getCharSequence(Notification.EXTRA_TEXT); + existingTitle = title == null ? null : title.toString(); + existingBody = body == null ? null : body.toString(); + } + } catch (Throwable error) { + logger.w("Unable to inspect standard notification content", error); + } + + String metaTitle = metaInfo == null ? null : metaInfo.getTitle(); + String metaBody = metaInfo == null ? null : metaInfo.getDescription(); + String focusParameter = null; + if (configuration != null) { + try { + focusParameter = configuration.focusParam(null); + } catch (Throwable error) { + logger.w("Unable to read focus-notification parameter", error); + } + } + String fallbackTitle = packageName; + try { + CharSequence appName = Global.ApplicationNameCache().getAppName(context, packageName); + if (appName != null && appName.length() > 0) { + fallbackTitle = appName.toString(); + } + } catch (Throwable ignored) { + } + + FocusNotificationSafety.ResolvedContent resolved = + FocusNotificationSafety.resolveReadableContent( + firstReadable(existingTitle, metaTitle), + firstReadable(existingBody, metaBody), + focusParameter, + fallbackTitle, + "New notification"); + if (!hasReadableText(existingTitle)) { + notificationBuilder.setContentTitle(resolved.title()); + } + if (!hasReadableText(existingBody)) { + notificationBuilder.setContentText(resolved.body()); + } + return resolved; + } + + @Nullable + private static String firstReadable(@Nullable String first, @Nullable String second) { + return hasReadableText(first) ? first : second; + } + + private static boolean hasReadableText(@Nullable String value) { + return value != null && !value.trim().isEmpty(); + } + + private static void applyOfficialMetadata( + Context context, + String packageName, + NotificationCompat.Builder builder, + CustomConfiguration configuration) { + NotificationMetadata metadata = NotificationMetadata.from(configuration); + Bundle extras = builder.getExtras(); + String customAppIconUri = configuration.notificationCustomSmallIconUri(null); + if (!TextUtils.isEmpty(customAppIconUri) + && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + Bitmap customAppIcon = getBitmapFromUri(context, customAppIconUri, 200 * KiB); + if (customAppIcon != null) { + extras.putParcelable("miui.appIcon", Icon.createWithBitmap(customAppIcon)); + extras.putString("custom_app_icon", "0"); + } + } + + String smallIconUri = configuration.notificationSmallIconUri(null); + if (!TextUtils.isEmpty(smallIconUri) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + Bitmap icon = getBitmapFromUri(context, smallIconUri, 200 * KiB); + if (icon != null) { + builder.setSmallIcon(IconCompat.createWithBitmap(icon)); + } + } + + String smallIconColor = configuration.notificationSmallIconColor(null); + if (!TextUtils.isEmpty(smallIconColor)) { + try { + builder.setColor(Color.parseColor(smallIconColor)); + } catch (IllegalArgumentException ignored) { + try { + builder.setColor(Integer.parseInt(smallIconColor)); + } catch (NumberFormatException ignoredToo) { + } + } + } + + if (metadata.timeoutSeconds != null && metadata.timeoutSeconds > 0) { + builder.setTimeoutAfter(metadata.timeoutSeconds * 1000L); + } + + Integer backgroundColor = metadata.backgroundColor; + if (backgroundColor != null) { + builder.setColor(backgroundColor); + if (metadata.ongoing == null) builder.setOngoing(true); + if (metadata.colorized == null) builder.setColorized(true); + } + if (metadata.ongoing != null) builder.setOngoing(metadata.ongoing); + if (metadata.colorized != null) builder.setColorized(metadata.colorized); + if (metadata.visibility != null) builder.setVisibility(metadata.visibility); + if (metadata.category != null) builder.setCategory(metadata.category); + + if (configuration.notificationStyle() + == CustomConfiguration.NotificationStyle.COLORFUL) { + String colorfulStyleBackground = + configuration.notificationColorfulBackgroundColor(null); + if (!TextUtils.isEmpty(colorfulStyleBackground)) { + try { + // Portable approximation for ROMs without MIUI's private layout. + builder.setColor(Color.parseColor(colorfulStyleBackground)); + } catch (IllegalArgumentException ignored) { + try { + builder.setColor(Integer.parseInt(colorfulStyleBackground)); + } catch (NumberFormatException ignoredToo) { + } + } + } + } + + String imageDescription = configuration.imageDescription(null); + if (!TextUtils.isEmpty(imageDescription)) { + extras.putCharSequence("miui.imageDescribe", imageDescription); + } + if (metadata.enableKeyguard != null) extras.putBoolean("miui.enableKeyguard", metadata.enableKeyguard); + if (metadata.enableFloat != null) extras.putBoolean("miui.enableFloat", metadata.enableFloat); + if (metadata.fold != null) extras.putString("notification_fold", metadata.fold); + if (metadata.foldTimeoutSeconds != null && metadata.foldTimeoutSeconds > 0) { + extras.putLong("miui.fold.timeout", metadata.foldTimeoutSeconds * 1000L); + } + + String styleType = configuration.notificationStyleType(null); + if (!TextUtils.isEmpty(styleType)) { + extras.putString("miui.notificationStyleType", styleType); + } + String colorfulText = configuration.notificationColorfulButtonText(null); + if (!TextUtils.isEmpty(colorfulText)) { + extras.putString("miui.colorfulButtonText", colorfulText); + } + String colorfulBackground = configuration.notificationColorfulButtonBackgroundColor(null); + if (!TextUtils.isEmpty(colorfulBackground)) { + extras.putString("miui.colorfulButtonBackgroundColor", colorfulBackground); + } + if (Boolean.TRUE.equals(metadata.topRepeat) + && metadata.topPeriodSeconds != null + && metadata.topPeriodSeconds > 0 + && metadata.topFrequency != null + && metadata.topFrequency >= 0 + && metadata.topFrequency <= metadata.topPeriodSeconds) { + builder.setPriority(Notification.PRIORITY_MAX); + long originalWhen = builder.build().when; + extras.putLong("mipush_org_when", originalWhen); + extras.putBoolean("mipush_n_top_flag", true); + extras.putInt("mipush_n_top_prd", metadata.topPeriodSeconds); + if (metadata.topFrequency > 0) { + extras.putInt("mipush_n_top_fre", metadata.topFrequency); + } + } + extras.putString("mipush_target_package", packageName); + extras.putString("xmsf_target_package", packageName); + } + + /** + * HyperOS/MIUI uses a hidden extraNotification target package to attribute a + * provider-posted notification to its real client. Keep the normal extras as + * a portable fallback, then use reflection only where the platform exposes + * the same system API. + */ + private static void applyTargetPackage(Context context, Notification notification, + String packageName) { + if (notification == null || TextUtils.isEmpty(packageName)) { + return; + } + boolean targetApplied = false; + try { + // Official XMSF reads the public field. Some HyperOS builds expose + // it through a parent declaration, so keep a declared-field + // fallback for AOSP/older MIUI variants. + Field field; + try { + field = Notification.class.getField("extraNotification"); + } catch (NoSuchFieldException ignored) { + field = Notification.class.getDeclaredField("extraNotification"); + } + field.setAccessible(true); + Object extraNotification = field.get(notification); + if (extraNotification != null) { + try { + targetApplied = invokeMiuiMethod( + extraNotification, "setTargetPkg", packageName); + } catch (Throwable ignored) { + // Some HyperOS releases expose only part of MiuiNotification. + } + // Official XMSF mirrors miui.enableFloat into the hidden + // MiuiNotification object. The Bundle key alone is ignored by + // several SystemUI versions, which is why MessagingStyle + // notifications previously lacked the pull-down mini-window. + if (notification.extras != null + && notification.extras.containsKey("miui.enableFloat")) { + try { + invokeMiuiMethod(extraNotification, "setEnableFloat", + notification.extras.getBoolean("miui.enableFloat")); + } catch (Throwable ignored) { + // AOSP has no MiuiNotification setter. + } + } + } + } catch (Throwable ignored) { + // AOSP and non-MIUI builds do not expose this hidden API. + } + if (targetApplied) { + return; + } + try { + PackageManager packageManager = context.getPackageManager(); + CharSequence label = packageManager.getApplicationLabel( + packageManager.getApplicationInfo(packageName, 0)); + notification.extras.putCharSequence("android.substName", label); + } catch (Throwable ignored) { + } + } + + private static boolean usesXiaomiSystemFocusRenderer(Context context) { + if (context == null) { + return false; + } + int protocolVersion = readFocusProtocolVersion(context); + String systemUiPackage = findXiaomiSystemUiPackage(context); + return DeviceFocusPolicy.rendererFor( + systemUiPackage, Build.MANUFACTURER, protocolVersion) + == DeviceFocusPolicy.Renderer.SYSTEM; + } + + /** + * Return a package that is actually installed on the device and is known to + * host Xiaomi's focus renderer. The package-manager probe is deliberately + * best effort: an AOSP device must never be classified as Xiaomi merely + * because a compatibility module uses a MIUI class namespace. + */ + @Nullable + private static String findXiaomiSystemUiPackage(Context context) { + if (!DeviceFocusPolicy.isXiaomiManufacturer(Build.MANUFACTURER)) { + return null; + } + PackageManager packageManager = context.getPackageManager(); + String[] candidates = { + "com.android.systemui", + "miui.systemui.plugin", + "com.miui.aod" + }; + for (String candidate : candidates) { + try { + packageManager.getApplicationInfo(candidate, 0); + return candidate; + } catch (PackageManager.NameNotFoundException ignored) { + // Try the next known host. Some HyperOS releases package the + // plugin separately while others keep it inside SystemUI. + } catch (Throwable error) { + logger.w("Unable to inspect Xiaomi SystemUI package", error); + return null; + } + } + return null; + } + + /** + * Invoke a MiuiNotification setter across HyperOS class hierarchies. + * Several releases return a private subclass whose setter is declared on + * a parent; getDeclaredMethod() on the concrete class alone silently misses + * that API and leaves SystemUI without the target package/float hint. + */ + private static boolean invokeMiuiMethod(Object target, String name, Object argument) + throws ReflectiveOperationException { + Class current = target.getClass(); + while (current != null) { + for (Method method : current.getDeclaredMethods()) { + if (!name.equals(method.getName()) || method.getParameterTypes().length != 1) { + continue; + } + Class parameterType = method.getParameterTypes()[0]; + if (argument == null || box(parameterType).isInstance(argument)) { + method.setAccessible(true); + method.invoke(target, argument); + return true; + } + } + current = current.getSuperclass(); + } + // Public inherited methods are not always returned by the loop above + // when a vendor class uses bridge methods. + for (Method method : target.getClass().getMethods()) { + if (!name.equals(method.getName()) || method.getParameterTypes().length != 1) { + continue; + } + Class parameterType = method.getParameterTypes()[0]; + if (argument == null || box(parameterType).isInstance(argument)) { + method.setAccessible(true); + method.invoke(target, argument); + return true; + } + } + return false; + } + + private static Class box(Class type) { + if (!type.isPrimitive()) return type; + if (type == Boolean.TYPE) return Boolean.class; + if (type == Byte.TYPE) return Byte.class; + if (type == Character.TYPE) return Character.class; + if (type == Short.TYPE) return Short.class; + if (type == Integer.TYPE) return Integer.class; + if (type == Long.TYPE) return Long.class; + if (type == Float.TYPE) return Float.class; + if (type == Double.TYPE) return Double.class; + return type; + } + + private static void applyAlertBehavior( + PushMetaInfo metaInfo, + String packageName, + NotificationCompat.Builder notificationBuilder) { + int notifyType = metaInfo == null ? 0 : metaInfo.getNotifyType(); + String soundUri = metaInfo == null + ? null + : XMPushUtils.getConfiguration(metaInfo).soundUri(null); + + if (usesPackageResourceSound(notifyType, soundUri, packageName)) { + notificationBuilder.setDefaults(notifyType & ~NOTIFY_TYPE_SOUND); + notificationBuilder.setSound(Uri.parse(soundUri)); + } else { + notificationBuilder.setDefaults(notifyType); + } + } + + private static void addFocusNotificationExtras( + Context context, + String packageName, + NotificationCompat.Builder notificationBuilder, + CustomConfiguration configuration) { + CustomConfiguration.FocusNotificationPayload payload = + configuration.focusNotificationPayload(); + if (!payload.isUsable()) { + return; + } + + Bundle focusBundle = new Bundle(); + if (FocusNotificationSafety.isWellFormedParameter(payload.parameter())) { + focusBundle.putString(FOCUS_PARAM, payload.parameter()); + } + // HyperOS uses a separate JSON object for app-specific CUSTOM focus + // templates (for example transit-card and payment notifications). It + // is safe to forward as a bounded string, but the associated actions + // Bundle/RemoteViews are intentionally not synthesized here: they are + // Parcelable objects owned by the originating app and are not present + // in PushMetaInfo.extra's String map. + if (FocusNotificationSafety.isWellFormedParameter(payload.customParameter())) { + focusBundle.putString(FOCUS_PARAM_CUSTOM, payload.customParameter()); + } + for (Map.Entry picture : payload.pictureUrls().entrySet()) { + // Keep the URL aliases exactly as received. This is the part of + // Xiaomi's original protocol that remains useful even when the + // native focus renderer is unavailable. + focusBundle.putString(picture.getKey(), picture.getValue()); + } + boolean appIconRequested = referencesApplicationIcon(configuration, payload); + Map downloadablePictures = + new java.util.LinkedHashMap<>(payload.downloadPictureUrls()); + // The app-icon alias is resolved locally from the target package. Do + // not spend the image budget fetching a value supplied under that key + // (some producers send a stale URL there as a compatibility hint). + downloadablePictures.remove(FocusNotificationSafety.FOCUS_APP_ICON_PICTURE); + boolean nativePictureDownloadsEnabled = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M + && isFocusProtocolEnabled(context) + && !downloadablePictures.isEmpty(); + // The application icon is not a URL download. Xiaomi's templates + // reference it by the literal alias from param_v2, so enrich it even + // when the ROM did not expose the optional protocol setting. The + // bundle is still only attached on the Xiaomi focus path (the caller + // never invokes this method for portable/AOSP notifications). + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M + && (nativePictureDownloadsEnabled || appIconRequested)) { + // Native Icon enrichment is bounded (count, bytes, executor queue + // and caller budget). Match official XMSF by doing this optional + // download only when the ROM advertises notification_focus_protocol; + // the parameter and URL aliases above remain available as the + // legacy, no-download compatibility path on AOSP/unsupported ROMs. + Bundle picturesBundle = nativePictureDownloadsEnabled + ? FocusIconApi23.downloadPictures(context, downloadablePictures) + : new Bundle(); + if (appIconRequested) { + // Keep the alias present even if package lookup fails. The + // official renderer treats a present-but-null entry as a + // failed optional image and preserves the rest of the focus + // template; omitting the key can make param_v2 reject the + // entire focus notification on HyperOS. + picturesBundle.putParcelable( + FocusNotificationSafety.FOCUS_APP_ICON_PICTURE, + FocusIconApi23.loadApplicationIcon(context, packageName)); + } + focusBundle.putBundle(FOCUS_PICTURES, picturesBundle); + } + notificationBuilder.addExtras(focusBundle); + } + + /** + * Detect the launcher-icon alias both in the normal focus JSON and in the + * occasional legacy top-level {@code param_v2} extra emitted by Xiaomi + * push producers. The latter is intentionally restricted to the two + * documented key spellings so arbitrary application metadata cannot turn + * on native icon work. + */ + private static boolean referencesApplicationIcon( + CustomConfiguration configuration, + CustomConfiguration.FocusNotificationPayload payload) { + String alias = FocusNotificationSafety.FOCUS_APP_ICON_PICTURE; + if (payload.pictureUrls().containsKey(alias) + || payload.pictureUrls().containsValue(alias) + || FocusNotificationSafety.referencesPictureAlias(payload.parameter(), alias) + || FocusNotificationSafety.referencesPictureAlias(payload.customParameter(), alias)) { + return true; + } + try { + for (String key : configuration.keys()) { + if ("param_v2".equals(key) || "miui.focus.param_v2".equals(key)) { + if (FocusNotificationSafety.referencesPictureAlias( + configuration.get(key, null), alias)) { + return true; + } + } + } + } catch (Throwable error) { + // Optional metadata must never block the standard notification. + logger.w("Unable to inspect legacy focus param_v2 metadata", error); + } + return false; + } + + private static boolean isFocusProtocolEnabled(Context context) { + if (context == null) { + return false; + } + if (!"com.xiaomi.xmsf".equals(context.getPackageName())) { + return false; + } + return FOCUS_PROTOCOL_SUPPORT_CACHE.get(SystemClock.elapsedRealtime(), () -> { + int protocolVersion = readFocusProtocolVersion(context); + return CustomConfiguration.FocusNotificationPayload + .isSupportedProtocolVersion(protocolVersion); + }); + } + + private static int readFocusProtocolVersion(Context context) { + if (context == null) { + return 0; + } + try { + return Settings.System.getInt(context.getContentResolver(), + FOCUS_PROTOCOL_SETTING, 0); + } catch (Throwable error) { + logger.w("Unable to read focus-notification protocol setting", error); + return 0; + } + } + + @RequiresApi(Build.VERSION_CODES.M) + private static final class FocusIconApi23 { + private static final int IMAGE_CACHE_MAX_BYTES = 4 * 1024 * 1024; + private static final ExecutorService IMAGE_EXECUTOR = createImageExecutor(); + private static final ConcurrentHashMap> IN_FLIGHT = + new ConcurrentHashMap<>(); + private static final LruCache IMAGE_CACHE = + new LruCache(IMAGE_CACHE_MAX_BYTES) { + @Override + protected int sizeOf(String key, Bitmap value) { + if (value == null || value.isRecycled()) return 1; + return Math.max(1, value.getAllocationByteCount()); + } + }; + + private FocusIconApi23() { + } + + static void trimMemory(int level) { + if (level >= android.content.ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL) { + IMAGE_CACHE.evictAll(); + } else if (level >= android.content.ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW) { + IMAGE_CACHE.trimToSize(IMAGE_CACHE_MAX_BYTES / 2); + } else if (level >= android.content.ComponentCallbacks2.TRIM_MEMORY_RUNNING_MODERATE) { + IMAGE_CACHE.trimToSize(IMAGE_CACHE_MAX_BYTES / 4); + } + } + + private static ExecutorService createImageExecutor() { + AtomicInteger threadNumber = new AtomicInteger(); + ThreadFactory threadFactory = runnable -> { + Thread thread = new Thread(runnable, + "mipush-focus-image-" + threadNumber.incrementAndGet()); + thread.setPriority(Thread.MIN_PRIORITY); + return thread; + }; + ThreadPoolExecutor executor = new ThreadPoolExecutor( + 3, 3, 30L, TimeUnit.SECONDS, + new LinkedBlockingQueue<>(30), threadFactory); + executor.allowCoreThreadTimeOut(true); + return executor; + } + + /** + * Resolve the target application's launcher icon as a framework + * {@link Icon}. HyperOS focus templates expect a native Icon in the + * {@code miui.focus.pics} Bundle; a Bitmap/Drawable or the regular + * notification largeIcon is not interchangeable there. + * + *

The common icon cache keeps this lookup bounded and avoids + * repeatedly decoding the same adaptive icon for bursts of push + * messages. Failure is deliberately represented by {@code null}; the + * caller keeps the alias in the Bundle and the ordinary notification + * path remains intact.

+ */ + @Nullable + static Icon loadApplicationIcon(Context context, String packageName) { + if (context == null || TextUtils.isEmpty(packageName)) { + return null; + } + try { + Bitmap bitmap = Global.IconCache().getRawIconBitmap(context, packageName); + if (bitmap != null && !bitmap.isRecycled()) { + Bitmap boundedBitmap = prepareLargeIconForNotification( + context, null, bitmap); + if (boundedBitmap != null) { + return Icon.createWithBitmap(boundedBitmap); + } + } + } catch (Throwable error) { + logger.w("Unable to resolve target app icon for focus notification", error); + } + // The shared cache may contain a bitmap that was trimmed/recycled + // by another notification style. Retry directly through the + // PackageManager before giving up so a transient cache state does + // not remove the app-icon alias from an otherwise valid focus + // notification. + try { + Drawable drawable = context.getPackageManager() + .getApplicationIcon(packageName); + Bitmap bitmap = ImgUtils.drawableToBitmap(drawable); + if (bitmap != null && !bitmap.isRecycled()) { + Bitmap boundedBitmap = prepareLargeIconForNotification( + context, null, bitmap); + if (boundedBitmap != null) { + return Icon.createWithBitmap(boundedBitmap); + } + } + } catch (Throwable error) { + logger.w("Unable to load target app icon from PackageManager", error); + } + return null; + } + + static Bundle downloadPictures( + Context context, Map pictureUrls) { + List> pictures = + new ArrayList<>(pictureUrls.entrySet()); + List> futures = new ArrayList<>(pictures.size()); + for (Map.Entry picture : pictures) { + futures.add(getOrStartDownload(context, picture.getValue())); + } + + Bundle result = new Bundle(); + long deadlineNanos = System.nanoTime() + + TimeUnit.MILLISECONDS.toNanos( + FocusNotificationSafety.IMAGE_ENRICHMENT_BUDGET_MILLIS); + for (int i = 0; i < pictures.size(); i++) { + Bitmap bitmap = null; + long remainingNanos = deadlineNanos - System.nanoTime(); + if (remainingNanos > 0L) { + try { + bitmap = futures.get(i).get(remainingNanos, TimeUnit.NANOSECONDS); + } catch (ExecutionException | TimeoutException error) { + logger.w("Unable to download focus-notification picture", error); + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); + } catch (java.util.concurrent.CancellationException error) { + } + } + Icon icon = null; + if (bitmap != null && !bitmap.isRecycled()) { + try { + icon = Icon.createWithBitmap(bitmap); + } catch (Throwable error) { + logger.w("Unable to create native focus-notification icon", error); + } + } + // Official XMSF retains the key with a null value on failure. + result.putParcelable(pictures.get(i).getKey(), icon); + } + // Keep the URL keys in the parent focus bundle even when one or + // more native icons failed. The URL is part of Xiaomi's original + // protocol; a null/omitted native Icon is the documented safe + // degradation. Binder/build failures are handled by publish's + // single focus-stripping retry, while a slow or bad image never + // blocks the standard notification past the caller budget. + return result; + } + + private static CompletableFuture getOrStartDownload( + Context context, String url) { + if (url == null) { + return CompletableFuture.completedFuture(null); + } + Bitmap cached = IMAGE_CACHE.get(url); + if (cached != null && !cached.isRecycled()) { + return CompletableFuture.completedFuture(cached); + } + + CompletableFuture created = new CompletableFuture<>(); + CompletableFuture existing = IN_FLIGHT.putIfAbsent(url, created); + if (existing != null) return existing; + + try { + IMAGE_EXECUTOR.execute(() -> { + try { + Bitmap bitmap = downloadPicture(context, url); + if (bitmap != null && !bitmap.isRecycled() + && url.regionMatches(true, 0, "https://", 0, 8)) { + IMAGE_CACHE.put(url, bitmap); + } + created.complete(bitmap); + } catch (Throwable error) { + logger.w("Unable to decode focus-notification picture", error); + created.complete(null); + } finally { + IN_FLIGHT.remove(url, created); + } + }); + } catch (RejectedExecutionException error) { + IN_FLIGHT.remove(url, created); + created.complete(null); + } + return created; + } + + @Nullable + private static Bitmap downloadPicture(Context context, String url) { + // Ask the bounded reader for one extra byte so exactly 100 KiB remains + // valid while a larger response is rejected. + MyNotificationIconHelper.GetIconResult result; + if (url != null && (url.regionMatches(true, 0, "content://", 0, 10) + || url.regionMatches(true, 0, "android.resource://", 0, 19))) { + result = MyNotificationIconHelper.getFocusIconFromUri(context, url, + CustomConfiguration.FOCUS_PICTURE_MAX_BYTES); + } else { + result = MyNotificationIconHelper.getFocusIconFromUrl(context, url, + CustomConfiguration.FOCUS_PICTURE_MAX_BYTES + 1); + } + if (result == null || result.bitmap == null + || !CustomConfiguration.FocusNotificationPayload + .isPictureSizeAllowed(result.downloadSize)) { + return null; + } + return result.bitmap; + } + } + @Nullable public static Bitmap getLargeIcon(Context context, PushMetaInfo metaInfo, String iconUri) { Bitmap largeIcon = Global.IconCache().getBitmap(context, iconUri, (context1, iconUri1) -> getBitmapFromUri(context1, iconUri1, 200 * KiB)); - if (largeIcon != null) { - largeIcon = roundLargeIconIfConfigured(metaInfo, largeIcon); + return prepareLargeIconForNotification(context, metaInfo, largeIcon); + } + + /** + * Keep notification icons small enough for SystemUI/Binder while preserving + * the configured circular treatment. The source bitmap can come from the + * shared icon cache, so it is never recycled here. + */ + @Nullable + public static Bitmap prepareLargeIconForNotification( + Context context, PushMetaInfo metaInfo, Bitmap largeIcon) { + if (largeIcon == null || largeIcon.isRecycled()) { + return null; + } + try { + int maxDimension = resolveNotificationLargeIconSize(context); + int width = largeIcon.getWidth(); + int height = largeIcon.getHeight(); + Bitmap boundedIcon = largeIcon; + int sourceMax = Math.max(width, height); + if (sourceMax > maxDimension) { + float scale = (float) maxDimension / sourceMax; + int boundedWidth = Math.max(1, Math.round(width * scale)); + int boundedHeight = Math.max(1, Math.round(height * scale)); + boundedIcon = Bitmap.createScaledBitmap( + largeIcon, boundedWidth, boundedHeight, true); + } + return roundLargeIconIfConfigured(metaInfo, boundedIcon); + } catch (Throwable error) { + // An icon is decoration only. Do not let a malformed/oversized + // bitmap prevent the underlying notification from being posted. + logger.w("Unable to prepare notification large icon", error); + return null; } - return largeIcon; + } + + private static int resolveNotificationLargeIconSize(@Nullable Context context) { + float density = 1f; + if (context != null && context.getResources() != null + && context.getResources().getDisplayMetrics() != null) { + density = context.getResources().getDisplayMetrics().density; + } + int size = Math.round(NOTIFICATION_LARGE_ICON_DP * density); + return Math.min(MAX_NOTIFICATION_LARGE_ICON_PX, + Math.max(MIN_NOTIFICATION_LARGE_ICON_PX, size)); } public static Bitmap roundLargeIconIfConfigured(PushMetaInfo metaInfo, Bitmap largeIcon) { @@ -213,12 +1410,15 @@ public static Bitmap getBitmapFromUri(Context context, String iconUri, int maxDo public static void cancel(Context context, XmPushActionContainer container, int notificationId, String notificationGroup, boolean clearGroup) { - getNotificationManagerEx().cancel(container.getPackageName(), + String packageName = MyMIPushNotificationHelper.getNotificationTargetPackage(container); + getNotificationManagerEx().cancel(packageName, getNotificationTag(container), notificationId); if (clearGroup) { - getNotificationManagerEx().cancel(container.getPackageName(), - getNotificationTag(container), notificationGroup.hashCode()); + if (notificationGroup != null) { + getNotificationManagerEx().cancel(packageName, + getNotificationTag(container), notificationGroup.hashCode()); + } return; } @@ -226,11 +1426,11 @@ public static void cancel(Context context, XmPushActionContainer container, if (notificationGroup != null) { XmPushActionContainer copy = container.deepCopy(); try { - Configurations.getInstance().handle(container.packageName, copy); + Configurations.getInstance().handle(packageName, copy); } catch (Throwable e) { e.printStackTrace(); } - updateSummaryNotification(context, copy.metaInfo, container.getPackageName(), notificationGroup); + updateSummaryNotification(context, copy.metaInfo, packageName, notificationGroup); } } } @@ -336,17 +1536,60 @@ private static int getIconId(Context context, String packageName, String resourc public static void test(Context context, String packageName, String title, String description) { - NotificationChannelManager.registerChannelIfNeeded(context, new PushMetaInfo(), packageName); + test(context, packageName, title, description, new PushMetaInfo(), + nextMockNotificationId()); + } - int id = (int) (System.currentTimeMillis() / 1000L); + public static void testFocus(Context context, String packageName, String title, + String description) { + PushMetaInfo metaInfo = new PushMetaInfo(); + Map extras = new HashMap<>(); + try { + org.json.JSONObject json = new org.json.JSONObject(); + json.put("ticker", "MiPush Framework"); + json.put("title", title); + json.put("description", description); + extras.put(FOCUS_PARAM, json.toString()); + } catch (org.json.JSONException e) { + logger.e("Failed to construct focus JSON", e); + } + extras.put("miui.focus.pic_0", + "https://raw.githubusercontent.com/SherlockChiang/MiPushFramework/7e2eb27ef86a4ea29d4791a82dd5a557b7f14b62/art/ic_launcher-web.png"); + metaInfo.setExtra(extras); + test(context, packageName, title, description, metaInfo, + nextMockNotificationId()); + } + + /** + * Manual replay is a diagnostic action. Give every tap a fresh id so a + * previously dismissed test notification cannot be silently updated without + * producing a new entry/head-up alert on MIUI/SystemUI. + */ + private static int nextMockNotificationId() { + return MOCK_NOTIFICATION_SEQUENCE.updateAndGet(previous -> + previous == Integer.MAX_VALUE ? 10_000 : previous + 1); + } - NotificationCompat.Builder localBuilder = new NotificationCompat.Builder(context); + private static void test(Context context, String packageName, String title, + String description, PushMetaInfo metaInfo, int notificationId) { + NotificationChannelManager.registerDebugChannelIfNeeded(context, packageName); + String channelId = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O + ? NotificationChannelManager.DEBUG_CHANNEL_ID + : getExistsChannelId(context, metaInfo, packageName); + NotificationCompat.Builder localBuilder = new NotificationCompat.Builder(context, channelId); NotificationCompat.BigTextStyle style = new NotificationCompat.BigTextStyle(); style.bigText(description); style.setBigContentTitle(title); style.setSummaryText(description); localBuilder.setStyle(style); + // BigTextStyle's expanded title is not guaranteed to populate the + // standard EXTRA_TITLE/EXTRA_TEXT fields on every AndroidX/MIUI build. + // Keep the collapsed notification readable as well. + localBuilder.setContentTitle(title); + localBuilder.setContentText(description); + localBuilder.setTicker(title + ": " + description); + localBuilder.setSmallIcon(R.drawable.ic_notifications_black_24dp); localBuilder.setWhen(System.currentTimeMillis()); localBuilder.setShowWhen(true); @@ -362,7 +1605,7 @@ public static void test(Context context, String packageName, String title, Strin localBuilder.setContentIntent(notifyPendingIntent); - NotificationController.publish(context, new PushMetaInfo(), id, packageName, localBuilder); + NotificationController.publish(context, metaInfo, notificationId, packageName, localBuilder); } } diff --git a/push/src/main/java/com/xiaomi/xmsf/push/notification/NotificationManagerEx.kt b/push/src/main/java/com/xiaomi/xmsf/push/notification/NotificationManagerEx.kt index 3defbc5b5..683cd479b 100644 --- a/push/src/main/java/com/xiaomi/xmsf/push/notification/NotificationManagerEx.kt +++ b/push/src/main/java/com/xiaomi/xmsf/push/notification/NotificationManagerEx.kt @@ -6,20 +6,34 @@ import android.app.NotificationChannelGroup import android.app.NotificationManager import android.content.Context import android.os.Build +import android.os.SystemClock +import android.os.UserHandle import android.service.notification.StatusBarNotification +import androidx.annotation.RequiresApi import com.elvishew.xlog.XLog object NotificationManagerEx { private const val TAG = "NotificationManagerEx" private lateinit var notificationManager: NotificationManager + private lateinit var notificationContext: Context + private var notificationService: Any? = null + private var packageAttributionSupported: Boolean? = null + private var lastCapabilityProbeAt: Long = 0L + + private const val CAPABILITY_RETRY_INTERVAL_MS = 1_000L + private const val XMSF_FAKE_CONDITION_PROVIDER_PATH = "xmsf_fake_condition_provider_path" @JvmField var isHooked: Boolean = false @JvmStatic fun init(context: Context) { + notificationContext = context.applicationContext notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationService = invokeHidden(notificationManager, "getService", emptyArray()).value + packageAttributionSupported = null + lastCapabilityProbeAt = 0L } fun notify( @@ -27,7 +41,81 @@ object NotificationManagerEx { tag: String?, id: Int, notification: Notification ) { XLog.d(TAG, "notify() called with: packageName = $packageName, tag = $tag, id = $id, notification = $notification") - notificationManager.notify(tag, id, notification) + if (::notificationContext.isInitialized && packageName != notificationContext.packageName) { + // HyperOS 2 reads this official XMSF bridge extra on Android 10+ + // even when the public NotificationManager call is used. + notification.extras.putString("xmsf_target_package", packageName) + } + // Official XMSF switches to the package-attributed hidden API on + // Android 10/HyperOS. This is what lets SystemUI resolve the real + // client's channel, click target and focus renderer; the old bridge + // accidentally used the reverse SDK condition and therefore posted + // third-party notifications as XMSF on modern devices. + val postedAsPackage = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && + supportsPackageAttribution() && + notifyAsPackage(packageName, tag, id, notification) + if (!postedAsPackage) { + notificationManager.notify(tag, id, notification) + } + } + + /** + * Xiaomi's XMSF enables package attribution only when the private fake + * condition-provider bridge is installed. Probe the same capability before + * calling the hidden API; unsupported/AOSP builds retain the public fallback. + */ + private fun supportsPackageAttribution(): Boolean { + if (packageAttributionSupported == true) return true + if (!::notificationManager.isInitialized) return false + val now = SystemClock.elapsedRealtime() + if (packageAttributionSupported == false && + now - lastCapabilityProbeAt < CAPABILITY_RETRY_INTERVAL_MS + ) { + return false + } + lastCapabilityProbeAt = now + val supported = (invokeHidden( + "isSystemConditionProviderEnabled", + arrayOf(String::class.java), + arrayOf(XMSF_FAKE_CONDITION_PROVIDER_PATH), + ).value as? Boolean) == true + packageAttributionSupported = supported + return supported + } + + private fun notifyAsPackage( + packageName: String, + tag: String?, + id: Int, + notification: Notification, + ): Boolean { + if (!::notificationContext.isInitialized || packageName == notificationContext.packageName) { + return false + } + return try { + val method = try { + notificationManager.javaClass.getDeclaredMethod( + "notifyAsPackage", + String::class.java, + String::class.java, + Int::class.javaPrimitiveType, + Notification::class.java, + ) + } catch (_: NoSuchMethodException) { + notificationManager.javaClass.getMethod( + "notifyAsPackage", + String::class.java, + String::class.java, + Int::class.javaPrimitiveType, + Notification::class.java, + ) + } + method.isAccessible = true + method.invoke(notificationManager, packageName, tag, id, notification) + true + } catch (ignored: Throwable) { + false + } } fun cancel( @@ -35,7 +123,9 @@ object NotificationManagerEx { tag: String?, id: Int ) { XLog.d(TAG, "cancel() called with: packageName = $packageName, tag = $tag, id = $id") - notificationManager.cancel(tag, id) + if (!cancelAsPackage(packageName, tag, id)) { + notificationManager.cancel(tag, id) + } } fun createNotificationChannels( @@ -44,7 +134,9 @@ object NotificationManagerEx { ) { XLog.d(TAG, "createNotificationChannels() called with: packageName = $packageName, channels = $channels") if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - notificationManager.createNotificationChannels(channels) + if (!createNotificationChannelsAsPackage(packageName, channels)) { + notificationManager.createNotificationChannels(channels) + } } } @@ -54,7 +146,8 @@ object NotificationManagerEx { ): NotificationChannel? { XLog.d(TAG, "createNotificationChannels() called with: packageName = $packageName, channelId = $channelId") return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - notificationManager.getNotificationChannel(channelId) + getNotificationChannelAsPackage(packageName, channelId) + ?: notificationManager.getNotificationChannel(channelId) } else { null } @@ -65,7 +158,8 @@ object NotificationManagerEx { ): List? { XLog.d(TAG, "getNotificationChannels() called with: packageName = $packageName") return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - notificationManager.getNotificationChannels() + getNotificationChannelsAsPackage(packageName) + ?: notificationManager.getNotificationChannels() } else { emptyList() } @@ -77,7 +171,9 @@ object NotificationManagerEx { ) { XLog.d(TAG, "deleteNotificationChannel() called with: packageName = $packageName, channelId = $channelId") if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - notificationManager.deleteNotificationChannel(channelId) + if (!deleteNotificationChannelAsPackage(packageName, channelId)) { + notificationManager.deleteNotificationChannel(channelId) + } } } @@ -88,7 +184,9 @@ object NotificationManagerEx { ) { XLog.d(TAG, "createNotificationChannelGroups() called with: packageName = $packageName, groups = $groups") if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - notificationManager.createNotificationChannelGroups(groups) + if (!createNotificationChannelGroupsAsPackage(packageName, groups)) { + notificationManager.createNotificationChannelGroups(groups) + } } } @@ -98,8 +196,8 @@ object NotificationManagerEx { ): NotificationChannelGroup? { XLog.d(TAG, "getNotificationChannelGroup() called with: packageName = $packageName, groupId = $groupId") return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - TODO("compile error") - //notificationManager.getNotificationChannelGroup(groupId) + getNotificationChannelGroupAsPackage(packageName, groupId) + ?: notificationManager.getNotificationChannelGroup(groupId) } else { null } @@ -122,7 +220,237 @@ object NotificationManagerEx { ) { XLog.d(TAG, "deleteNotificationChannelGroup() called with: packageName = $packageName, groupId = $groupId") if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - notificationManager.deleteNotificationChannelGroup(groupId) + if (!deleteNotificationChannelGroupAsPackage(packageName, groupId)) { + notificationManager.deleteNotificationChannelGroup(groupId) + } + } + } + + /** + * MIUI/HyperOS exposes package-attributed NotificationManager methods which + * are hidden from the Android SDK. Keep these calls isolated and best-effort: + * an AOSP build (or a ROM that blocks hidden API access) simply falls back to + * the public XMSF operation. This preserves delivery while allowing SystemUI + * to resolve the client's channel, icon and focus policy when the bridge is + * available. + */ + private fun cancelAsPackage(packageName: String, tag: String?, id: Int): Boolean { + if (!supportsPackageAttribution() || + !::notificationContext.isInitialized || + packageName == notificationContext.packageName + ) { + return false + } + return invokeHidden( + "cancelAsPackage", + arrayOf(String::class.java, String::class.java, Int::class.javaPrimitiveType!!), + arrayOf(packageName, tag, id) + ).success + } + + @RequiresApi(Build.VERSION_CODES.O) + private fun createNotificationChannelsAsPackage( + packageName: String, + channels: List + ): Boolean { + val uid = packageUid(packageName) ?: return false + val slice = asParceledListSlice(channels) ?: return false + return invokeService( + "createNotificationChannelsForPackage", + arrayOf(packageName, uid, slice) + ).success + } + + @RequiresApi(Build.VERSION_CODES.O) + private fun getNotificationChannelAsPackage( + packageName: String, + channelId: String? + ): NotificationChannel? { + if (channelId == null) return null + return getNotificationChannelsAsPackage(packageName) + ?.firstOrNull { it?.id == channelId } + } + + @RequiresApi(Build.VERSION_CODES.O) + private fun getNotificationChannelsAsPackage( + packageName: String + ): List? { + val uid = packageUid(packageName) ?: return null + val result = invokeService( + "getNotificationChannelsForPackage", + arrayOf(packageName, uid, false) + ) + return unwrapList(result.value) + } + + @RequiresApi(Build.VERSION_CODES.O) + private fun createNotificationChannelGroupsAsPackage( + packageName: String, + groups: List + ): Boolean { + val slice = asParceledListSlice(groups) ?: return false + return invokeService( + "createNotificationChannelGroups", + arrayOf(packageName, slice) + ).success + } + + @RequiresApi(Build.VERSION_CODES.P) + private fun getNotificationChannelGroupAsPackage( + packageName: String, + groupId: String? + ): NotificationChannelGroup? { + if (groupId == null) return null + val uid = packageUid(packageName) ?: return null + return invokeService( + "getNotificationChannelGroupForPackage", + arrayOf(groupId, packageName, uid) + ).value as? NotificationChannelGroup + } + + @RequiresApi(Build.VERSION_CODES.O) + private fun deleteNotificationChannelGroupAsPackage( + packageName: String, + groupId: String? + ): Boolean { + if (groupId == null) return false + return invokeService( + "deleteNotificationChannelGroup", + arrayOf(packageName, groupId) + ).success + } + + @Suppress("UNCHECKED_CAST") + private fun unwrapList(value: Any?): List? { + if (value == null) return null + if (value is List<*>) return value as List + val list = invokeHidden(value, "getList", emptyArray()).value + @Suppress("UNCHECKED_CAST") + return list as? List + } + + @RequiresApi(Build.VERSION_CODES.O) + private fun deleteNotificationChannelAsPackage( + packageName: String, + channelId: String? + ): Boolean { + if (channelId == null) return false + val direct = invokeService( + "deleteNotificationChannel", + arrayOf(packageName, channelId) + ) + if (direct.success) return true + val uid = packageUid(packageName) ?: return false + return invokeService( + "deleteNotificationChannelForPackage", + arrayOf(packageName, uid, channelId) + ).success + } + + private data class HiddenCallResult(val success: Boolean, val value: Any?) + + private fun packageUid(packageName: String): Int? { + if (!::notificationContext.isInitialized) return null + if (packageName == notificationContext.packageName) return null + return try { + notificationContext.packageManager.getPackageUid(packageName, 0) + .takeIf { it >= 0 } + } catch (_: Throwable) { + null + } + } + + private fun asParceledListSlice(values: List<*>): Any? { + return try { + val type = Class.forName("android.content.pm.ParceledListSlice") + val constructor = type.getDeclaredConstructor(List::class.java) + constructor.isAccessible = true + constructor.newInstance(values) + } catch (_: Throwable) { + null + } + } + + private fun invokeService(methodName: String, args: Array): HiddenCallResult { + // Channel, permission and active-notification queries must use the + // same ownership model as notify(). Mixing target-owned channels with + // an XMSF-owned public notification makes Android reject the record. + if (!supportsPackageAttribution()) { + return HiddenCallResult(false, null) + } + return invokeHidden(notificationService, methodName, args) + } + + private fun invokeHidden( + methodName: String, + parameterTypes: Array>, + args: Array + ): HiddenCallResult { + if (!::notificationManager.isInitialized) return HiddenCallResult(false, null) + return try { + val method = try { + notificationManager.javaClass.getDeclaredMethod(methodName, *parameterTypes) + } catch (_: NoSuchMethodException) { + notificationManager.javaClass.getMethod(methodName, *parameterTypes) + } + method.isAccessible = true + HiddenCallResult(true, method.invoke(notificationManager, *args)) + } catch (_: Throwable) { + HiddenCallResult(false, null) + } + } + + private fun invokeHidden( + target: Any?, + methodName: String, + args: Array + ): HiddenCallResult { + if (target == null) return HiddenCallResult(false, null) + return try { + val methods = target.javaClass.methods.asSequence() + + target.javaClass.declaredMethods.asSequence() + val method = methods.firstOrNull { + it.name == methodName && parametersAccept(it.parameterTypes, args) + } ?: return HiddenCallResult(false, null) + method.isAccessible = true + HiddenCallResult(true, method.invoke(target, *args)) + } catch (_: Throwable) { + HiddenCallResult(false, null) + } + } + + private fun parametersAccept(types: Array>, args: Array): Boolean { + if (types.size != args.size) return false + return types.indices.all { index -> + val argument = args[index] + if (argument == null) { + !types[index].isPrimitive + } else { + boxed(types[index]).isInstance(argument) + } + } + } + + private fun boxed(type: Class<*>): Class<*> = when (type) { + java.lang.Boolean.TYPE -> java.lang.Boolean::class.java + java.lang.Byte.TYPE -> java.lang.Byte::class.java + java.lang.Character.TYPE -> java.lang.Character::class.java + java.lang.Short.TYPE -> java.lang.Short::class.java + java.lang.Integer.TYPE -> java.lang.Integer::class.java + java.lang.Long.TYPE -> java.lang.Long::class.java + java.lang.Float.TYPE -> java.lang.Float::class.java + java.lang.Double.TYPE -> java.lang.Double::class.java + else -> type + } + + private fun currentUserId(): Int { + return try { + val method = UserHandle::class.java.getDeclaredMethod("myUserId") + method.isAccessible = true + (method.invoke(null) as? Int) ?: 0 + } catch (_: Throwable) { + // Single-user devices (and AOSP SDK stubs) use user 0. + 0 } } @@ -131,7 +459,11 @@ object NotificationManagerEx { ): Boolean { XLog.d(TAG, "areNotificationsEnabled() called with: packageName = $packageName") return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - notificationManager.areNotificationsEnabled() + val uid = packageUid(packageName) + val attributed = if (uid == null) HiddenCallResult(false, null) else { + invokeService("areNotificationsEnabledForPackage", arrayOf(packageName, uid)) + } + (attributed.value as? Boolean) ?: notificationManager.areNotificationsEnabled() } else { true } @@ -142,10 +474,25 @@ object NotificationManagerEx { ): Array? { XLog.d(TAG, "getActiveNotifications() called with: packageName = $packageName") return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - notificationManager.getActiveNotifications() + val attributed = if (::notificationContext.isInitialized && + packageName != notificationContext.packageName + ) { + invokeService( + "getAppActiveNotifications", + arrayOf(packageName, currentUserId()) + ) + } else { + HiddenCallResult(false, null) + } + val list = unwrapList(attributed.value) + if (list != null) { + list.filterNotNull().toTypedArray() + } else { + notificationManager.getActiveNotifications() + } } else { emptyArray() } } -} \ No newline at end of file +} diff --git a/push/src/main/java/com/xiaomi/xmsf/push/service/MiuiPushActivateService.java b/push/src/main/java/com/xiaomi/xmsf/push/service/MiuiPushActivateService.java index c74d533cb..0a05b9ddc 100644 --- a/push/src/main/java/com/xiaomi/xmsf/push/service/MiuiPushActivateService.java +++ b/push/src/main/java/com/xiaomi/xmsf/push/service/MiuiPushActivateService.java @@ -14,6 +14,7 @@ import com.elvishew.xlog.Logger; import com.elvishew.xlog.XLog; +import com.xiaomi.xmsf.push.control.PushControllerUtils; import java.util.ArrayList; import java.util.List; @@ -37,6 +38,9 @@ public MiuiPushActivateService(String str) { } public static void awakePushActivateService(Context context, String str) { + if (context == null || !PushControllerUtils.isPrefsEnable(context)) { + return; + } try { Intent intent = new Intent(context, MiuiPushActivateService.class); intent.setPackage(context.getPackageName()); @@ -86,6 +90,9 @@ public void addRegisteredPackage(String str, String str2) { } protected void onHandleIntent(Intent intent) { + if (!PushControllerUtils.isPrefsEnable(this)) { + return; + } if ("com.xiaomi.xmsf.push.SCAN".equals(intent.getAction())) { long j = 0; for (final String str : getPackages()) { @@ -124,4 +131,4 @@ public void run() { } } } -} \ No newline at end of file +} diff --git a/push/src/main/java/com/xiaomi/xmsf/push/service/XMPushService.java b/push/src/main/java/com/xiaomi/xmsf/push/service/XMPushService.java index 4d8248156..af1e8b006 100644 --- a/push/src/main/java/com/xiaomi/xmsf/push/service/XMPushService.java +++ b/push/src/main/java/com/xiaomi/xmsf/push/service/XMPushService.java @@ -1,17 +1,15 @@ package com.xiaomi.xmsf.push.service; import android.app.IntentService; -import android.content.ComponentName; import android.content.Intent; import android.widget.Toast; -import androidx.core.content.ContextCompat; - import com.elvishew.xlog.Logger; import com.elvishew.xlog.XLog; import com.nihility.Global; import com.xiaomi.xmsf.R; import com.xiaomi.xmsf.push.control.PushControllerUtils; +import com.xiaomi.xmsf.push.control.PushServiceDispatcher; import com.xiaomi.xmsf.push.utils.Configurations; import com.xiaomi.xmsf.utils.ConvertUtils; @@ -47,11 +45,7 @@ protected void onHandleIntent(Intent intent) { } private void forwardToPushServiceMain(Intent intent) { - Intent intent2 = new Intent(); - intent2.setComponent(new ComponentName(this, com.xiaomi.push.service.XMPushService.class)); - intent2.setAction(intent.getAction()); - intent2.putExtras(intent); - ContextCompat.startForegroundService(this, intent2); + PushServiceDispatcher.dispatchIntent(this, intent, true); logger.d("forward intent " + ConvertUtils.toJson(intent)); } diff --git a/push/src/main/java/com/xiaomi/xmsf/push/service/receivers/BootReceiver.java b/push/src/main/java/com/xiaomi/xmsf/push/service/receivers/BootReceiver.java index b85cbe1e4..7aa7a25b7 100644 --- a/push/src/main/java/com/xiaomi/xmsf/push/service/receivers/BootReceiver.java +++ b/push/src/main/java/com/xiaomi/xmsf/push/service/receivers/BootReceiver.java @@ -5,6 +5,8 @@ import android.content.Intent; import com.xiaomi.push.service.ClientEventDispatcher; +import com.xiaomi.xmsf.push.control.PushControllerUtils; +import com.xiaomi.xmsf.push.control.PushServiceDispatcher; /** * Created by Trumeet on 2017/8/25. @@ -14,8 +16,15 @@ public class BootReceiver extends BroadcastReceiver { @Override public void onReceive(Context context, Intent intent) { - if (intent != null && intent.getAction().equals("android.intent.action.BOOT_COMPLETED")) { - new ClientEventDispatcher().notifyServiceStarted(context); + if (intent != null && "android.intent.action.BOOT_COMPLETED".equals(intent.getAction())) { + if (context == null || !PushControllerUtils.isPrefsEnable(context)) { + return; + } + PushServiceDispatcher.dispatchStart(context, false); + try { + new ClientEventDispatcher().notifyServiceStarted(context); + } catch (Throwable ignored) { + } } } } diff --git a/push/src/main/java/com/xiaomi/xmsf/push/service/receivers/KeepAliveReceiver.java b/push/src/main/java/com/xiaomi/xmsf/push/service/receivers/KeepAliveReceiver.java index e8be6668b..bbdb4e2c2 100644 --- a/push/src/main/java/com/xiaomi/xmsf/push/service/receivers/KeepAliveReceiver.java +++ b/push/src/main/java/com/xiaomi/xmsf/push/service/receivers/KeepAliveReceiver.java @@ -3,45 +3,64 @@ import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; - -import androidx.core.content.ContextCompat; +import android.os.SystemClock; import com.elvishew.xlog.Logger; import com.elvishew.xlog.XLog; import com.xiaomi.channel.commonutils.logger.MyLog; import com.xiaomi.push.service.PushServiceConstants; - - +import com.xiaomi.xmsf.push.control.PushControllerUtils; +import com.xiaomi.xmsf.push.control.PushServiceDispatcher; /** * @author zts */ public class KeepAliveReceiver extends BroadcastReceiver { + static final long MIN_START_INTERVAL_MS = 2 * 60 * 1000L; private final Logger logger = XLog.tag(KeepAliveReceiver.class.getSimpleName()).build(); - private long lastActive = System.currentTimeMillis(); + /* Zero means no recovery attempt has been made yet; the first valid screen-on is allowed. */ + private long lastActiveElapsedRealtime; public KeepAliveReceiver() { } @Override public void onReceive(Context context, Intent intent) { + if (intent == null || !Intent.ACTION_SCREEN_ON.equals(intent.getAction())) { + return; + } + if (!PushControllerUtils.isRegistrationRetryEnabled()) { + return; + } + // A live transport does not need a second start command on every screen + // wake. Avoid needless binder/service churn on HyperOS and third-party ROMs. + if (!shouldAttemptRecoveryForServiceState( + PushControllerUtils.isPushServiceRunning())) { + return; + } try { - long now = System.currentTimeMillis(); + long nowElapsedRealtime = SystemClock.elapsedRealtime(); - if ((now - lastActive) < (1000 * 60 * 2)) { + if (!shouldStart(lastActiveElapsedRealtime, nowElapsedRealtime)) { return; } - lastActive = now; - + lastActiveElapsedRealtime = nowElapsedRealtime; logger.d("start service when " + intent.getAction()); - Intent localIntent = new Intent(context, com.xiaomi.push.service.XMPushService.class); - localIntent.putExtra(PushServiceConstants.EXTRA_TIME_STAMP, now); - localIntent.setAction(PushServiceConstants.ACTION_CHECK_ALIVE); - ContextCompat.startForegroundService(context, localIntent); + PushServiceDispatcher.dispatchStart( + context, PushServiceConstants.ACTION_CHECK_ALIVE, false); } catch (Exception localException) { MyLog.e(localException); } } + + static boolean shouldStart(long lastElapsedRealtime, long nowElapsedRealtime) { + return lastElapsedRealtime == 0L + || nowElapsedRealtime - lastElapsedRealtime >= MIN_START_INTERVAL_MS; + } + + static boolean shouldAttemptRecoveryForServiceState(boolean serviceRunning) { + return !serviceRunning; + } } diff --git a/push/src/main/java/com/xiaomi/xmsf/push/service/receivers/MiPushPingReceiver.java b/push/src/main/java/com/xiaomi/xmsf/push/service/receivers/MiPushPingReceiver.java index 2702beeef..5756a3a9e 100644 --- a/push/src/main/java/com/xiaomi/xmsf/push/service/receivers/MiPushPingReceiver.java +++ b/push/src/main/java/com/xiaomi/xmsf/push/service/receivers/MiPushPingReceiver.java @@ -5,12 +5,11 @@ import android.content.Intent; import android.text.TextUtils; -import androidx.core.content.ContextCompat; - import com.xiaomi.channel.commonutils.logger.MyLog; import com.xiaomi.push.service.PushConstants; import com.xiaomi.push.service.PushServiceConstants; import com.xiaomi.push.service.timers.Alarm; +import com.xiaomi.xmsf.push.control.PushServiceDispatcher; public class MiPushPingReceiver extends BroadcastReceiver { @@ -18,20 +17,15 @@ public MiPushPingReceiver() { } public void onReceive(Context paramContext, Intent paramIntent) { + if (paramIntent == null) { + return; + } MyLog.v(paramIntent.getPackage() + " is the package name"); if (PushConstants.ACTION_PING_TIMER.equals(paramIntent.getAction())) { if (TextUtils.equals(paramContext.getPackageName(), paramIntent.getPackage())) { MyLog.v("Ping XMChannelService on timer"); - - try { - Intent localIntent = new Intent(paramContext, com.xiaomi.push.service.XMPushService.class); - localIntent.putExtra(PushServiceConstants.EXTRA_TIME_STAMP, System.currentTimeMillis()); - localIntent.setAction(PushServiceConstants.ACTION_TIMER); - ContextCompat.startForegroundService(paramContext, localIntent); - } catch (Exception localException) { - MyLog.e(localException); - } - + PushServiceDispatcher.dispatchStart( + paramContext, PushServiceConstants.ACTION_TIMER, false); } else { MyLog.w("cancel the old ping timer"); Alarm.stop(); diff --git a/push/src/main/java/com/xiaomi/xmsf/push/service/receivers/NetworkStatusReceiver.java b/push/src/main/java/com/xiaomi/xmsf/push/service/receivers/NetworkStatusReceiver.java index 4228f536e..dd8a29380 100644 --- a/push/src/main/java/com/xiaomi/xmsf/push/service/receivers/NetworkStatusReceiver.java +++ b/push/src/main/java/com/xiaomi/xmsf/push/service/receivers/NetworkStatusReceiver.java @@ -3,21 +3,114 @@ import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; - -import androidx.core.content.ContextCompat; +import android.os.SystemClock; import com.xiaomi.channel.commonutils.network.Network; import com.xiaomi.mipush.sdk.PushServiceClient; import com.xiaomi.smack.util.TrafficUtils; +import com.xiaomi.xmsf.push.control.PushControllerUtils; +import com.xiaomi.xmsf.push.control.PushServiceDispatcher; + +import java.util.concurrent.atomic.AtomicLong; public class NetworkStatusReceiver extends BroadcastReceiver { + private static final String ACTION_NETWORK_STATUS_CHANGED = + "com.xiaomi.push.network_status_changed"; + static final long MIN_RECOVERY_INTERVAL_MS = 60_000L; + /** Registration refresh is useful after reconnect, but connectivity broadcasts can burst. */ + static final long MIN_REGISTRATION_PROCESS_INTERVAL_MS = 5 * 60 * 1000L; + static final long NO_RECOVERY_ATTEMPT = Long.MIN_VALUE; + + private static final AtomicLong LAST_RECOVERY_ELAPSED_REALTIME = + new AtomicLong(NO_RECOVERY_ATTEMPT); + private static final AtomicLong LAST_REGISTRATION_PROCESS_ELAPSED_REALTIME = + new AtomicLong(NO_RECOVERY_ATTEMPT); + public void onReceive(Context context, Intent intent) { - Intent intent2 = new Intent(context, com.xiaomi.push.service.XMPushService.class); - intent2.setAction("com.xiaomi.push.network_status_changed"); - ContextCompat.startForegroundService(context, intent2); - TrafficUtils.notifyNetworkChanage(context); - if (Network.hasNetwork(context) && PushServiceClient.getInstance(context).isProvisioned()) { - PushServiceClient.getInstance(context).processRegisterTask(); + if (context == null || !PushControllerUtils.isPrefsEnable(context)) { + return; + } + + boolean hasNetwork = false; + try { + // CONNECTIVITY_CHANGE can be noisy on vendor ROMs. Resolve the + // state once so recovery and registration make the same decision. + hasNetwork = Network.hasNetwork(context); + } catch (Throwable ignored) { + } + + try { + TrafficUtils.notifyNetworkChanage(context); + } catch (Throwable ignored) { + } + + if (hasNetwork && !PushControllerUtils.isPushServiceRunning() + && claimRecovery(SystemClock.elapsedRealtime())) { + PushServiceDispatcher.dispatchStart( + context, ACTION_NETWORK_STATUS_CHANGED, false); + } + + try { + if (hasNetwork) { + PushServiceClient client = PushServiceClient.getInstance(context); + if (client.isProvisioned() + && claimRegistrationProcessing(SystemClock.elapsedRealtime())) { + client.processRegisterTask(); + } + } + } catch (Throwable ignored) { + } + } + + private static boolean claimRegistrationProcessing(long nowElapsedRealtime) { + while (true) { + long previous = LAST_REGISTRATION_PROCESS_ELAPSED_REALTIME.get(); + if (!shouldProcessRegistration(true, previous, nowElapsedRealtime)) { + return false; + } + if (LAST_REGISTRATION_PROCESS_ELAPSED_REALTIME.compareAndSet(previous, + nowElapsedRealtime)) { + return true; + } + } + } + + static boolean shouldProcessRegistration( + boolean hasNetwork, long previousElapsedRealtime, long nowElapsedRealtime) { + if (!hasNetwork) { + return false; + } + if (previousElapsedRealtime == NO_RECOVERY_ATTEMPT) { + return true; + } + long elapsed = nowElapsedRealtime - previousElapsedRealtime; + return elapsed < 0L || elapsed >= MIN_REGISTRATION_PROCESS_INTERVAL_MS; + } + + private static boolean claimRecovery(long nowElapsedRealtime) { + while (true) { + long previous = LAST_RECOVERY_ELAPSED_REALTIME.get(); + if (!shouldAttemptRecovery(true, false, previous, nowElapsedRealtime)) { + return false; + } + if (LAST_RECOVERY_ELAPSED_REALTIME.compareAndSet(previous, nowElapsedRealtime)) { + return true; + } + } + } + + static boolean shouldAttemptRecovery( + boolean hasNetwork, + boolean serviceRunning, + long previousElapsedRealtime, + long nowElapsedRealtime) { + if (!hasNetwork || serviceRunning) { + return false; + } + if (previousElapsedRealtime == NO_RECOVERY_ATTEMPT) { + return true; } + long elapsed = nowElapsedRealtime - previousElapsedRealtime; + return elapsed < 0L || elapsed >= MIN_RECOVERY_INTERVAL_MS; } } diff --git a/push/src/main/java/com/xiaomi/xmsf/push/service/receivers/PkgUninstallReceiver.java b/push/src/main/java/com/xiaomi/xmsf/push/service/receivers/PkgUninstallReceiver.java index bb1dbf25b..81ea4f5ec 100644 --- a/push/src/main/java/com/xiaomi/xmsf/push/service/receivers/PkgUninstallReceiver.java +++ b/push/src/main/java/com/xiaomi/xmsf/push/service/receivers/PkgUninstallReceiver.java @@ -5,10 +5,9 @@ import android.content.Intent; import android.net.Uri; -import androidx.core.content.ContextCompat; - import com.xiaomi.channel.commonutils.logger.MyLog; import com.xiaomi.push.service.PushServiceConstants; +import com.xiaomi.xmsf.push.control.PushServiceDispatcher; public class PkgUninstallReceiver extends BroadcastReceiver { public PkgUninstallReceiver() { @@ -23,7 +22,7 @@ public void onReceive(Context var1, Intent var2) { Intent var5 = new Intent(var1, com.xiaomi.push.service.XMPushService.class); var5.setAction(PushServiceConstants.ACTION_UNINSTALL); var5.putExtra(PushServiceConstants.EXTRA_UNINSTALL_PKG_NAME, var4.getEncodedSchemeSpecificPart()); - ContextCompat.startForegroundService(var1, var5); + PushServiceDispatcher.dispatchIntent(var1, var5, false); } catch (Exception var7) { MyLog.e(var7); } diff --git a/push/src/main/java/com/xiaomi/xmsf/push/utils/ConfigurationDiagnosticsSnapshot.java b/push/src/main/java/com/xiaomi/xmsf/push/utils/ConfigurationDiagnosticsSnapshot.java new file mode 100644 index 000000000..6a9507a10 --- /dev/null +++ b/push/src/main/java/com/xiaomi/xmsf/push/utils/ConfigurationDiagnosticsSnapshot.java @@ -0,0 +1,59 @@ +package com.xiaomi.xmsf.push.utils; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** + * Immutable, payload-free status for the most recently published configuration load. + * + *

The snapshot deliberately contains only structured configuration-reference metadata. It + * never retains source JSON, exceptions, or notification payloads.

+ */ +public final class ConfigurationDiagnosticsSnapshot { + public enum Status { + NOT_CONFIGURED, + READY, + FAILED + } + + private static final ConfigurationDiagnosticsSnapshot NOT_CONFIGURED = + new ConfigurationDiagnosticsSnapshot(Status.NOT_CONFIGURED, Collections.emptyList()); + private static final ConfigurationDiagnosticsSnapshot FAILED = + new ConfigurationDiagnosticsSnapshot(Status.FAILED, Collections.emptyList()); + + private final Status status; + private final List + unresolvedReferences; + + private ConfigurationDiagnosticsSnapshot( + Status status, + List unresolvedReferences) { + this.status = Objects.requireNonNull(status, "status"); + this.unresolvedReferences = Collections.unmodifiableList( + new ArrayList<>(Objects.requireNonNull( + unresolvedReferences, "unresolvedReferences"))); + } + + public static ConfigurationDiagnosticsSnapshot notConfigured() { + return NOT_CONFIGURED; + } + + public static ConfigurationDiagnosticsSnapshot ready( + List unresolvedReferences) { + return new ConfigurationDiagnosticsSnapshot(Status.READY, unresolvedReferences); + } + + public static ConfigurationDiagnosticsSnapshot failed() { + return FAILED; + } + + public Status getStatus() { + return status; + } + + public List getUnresolvedReferences() { + return unresolvedReferences; + } +} diff --git a/push/src/main/java/com/xiaomi/xmsf/push/utils/ConfigurationReferenceDiagnostics.java b/push/src/main/java/com/xiaomi/xmsf/push/utils/ConfigurationReferenceDiagnostics.java new file mode 100644 index 000000000..ca95a22d3 --- /dev/null +++ b/push/src/main/java/com/xiaomi/xmsf/push/utils/ConfigurationReferenceDiagnostics.java @@ -0,0 +1,90 @@ +package com.xiaomi.xmsf.push.utils; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; + +/** Pure-Java resolution and metadata for configuration-to-configuration references. */ +public final class ConfigurationReferenceDiagnostics { + private ConfigurationReferenceDiagnostics() { + } + + public static UnresolvedReference referenceSite( + String sourceName, String ownerKey, String reference) { + return new UnresolvedReference(sourceName, ownerKey, reference); + } + + public static List resolve( + Collection loadedConfigKeys, + Collection referenceSites) { + HashSet unresolved = new HashSet<>(); + for (UnresolvedReference site : referenceSites) { + if (!loadedConfigKeys.contains(site.getReference())) { + unresolved.add(site); + } + } + List sorted = new ArrayList<>(unresolved); + sorted.sort(Comparator + .comparing(UnresolvedReference::getSourceName) + .thenComparing(UnresolvedReference::getOwnerKey) + .thenComparing(UnresolvedReference::getReference)); + return Collections.unmodifiableList(sorted); + } + + /** Structured config metadata only; notification payloads are never fields of this type. */ + public static final class UnresolvedReference { + private final String sourceName; + private final String ownerKey; + private final String reference; + + private UnresolvedReference(String sourceName, String ownerKey, String reference) { + this.sourceName = sourceName == null ? "" : sourceName; + this.ownerKey = Objects.requireNonNull(ownerKey, "ownerKey"); + this.reference = Objects.requireNonNull(reference, "reference"); + } + + public String getSourceName() { + return sourceName; + } + + public String getOwnerKey() { + return ownerKey; + } + + public String getReference() { + return reference; + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof UnresolvedReference)) { + return false; + } + UnresolvedReference that = (UnresolvedReference) other; + return sourceName.equals(that.sourceName) + && ownerKey.equals(that.ownerKey) + && reference.equals(that.reference); + } + + @Override + public int hashCode() { + return Objects.hash(sourceName, ownerKey, reference); + } + + @Override + public String toString() { + return "UnresolvedReference{" + + "sourceName='" + sourceName + '\'' + + ", ownerKey='" + ownerKey + '\'' + + ", reference='" + reference + '\'' + + '}'; + } + } +} diff --git a/push/src/main/java/com/xiaomi/xmsf/push/utils/Configurations.java b/push/src/main/java/com/xiaomi/xmsf/push/utils/Configurations.java index 14ef2651f..2d33f50d4 100644 --- a/push/src/main/java/com/xiaomi/xmsf/push/utils/Configurations.java +++ b/push/src/main/java/com/xiaomi/xmsf/push/utils/Configurations.java @@ -44,6 +44,16 @@ public void load(String json) throws JSONException { loader.load(json); } + /** Returns unresolved config references without inspecting notification payloads. */ + public List getUnresolvedReferences() { + return loader.getUnresolvedReferences(); + } + + /** Returns the immutable status and diagnostics for the last published configuration load. */ + public ConfigurationDiagnosticsSnapshot getDiagnosticsSnapshot() { + return loader.getDiagnosticsSnapshot(); + } + public Set handle(String packageName, XmPushActionContainer data) throws JSONException, NoSuchFieldException, IllegalAccessException, InvocationTargetException, NoSuchMethodException { String[] checkPkgs = new String[]{"^", packageName, "$"}; diff --git a/push/src/main/java/com/xiaomi/xmsf/push/utils/ConfigurationsLoader.java b/push/src/main/java/com/xiaomi/xmsf/push/utils/ConfigurationsLoader.java index 36c9aa61a..e0bdda637 100644 --- a/push/src/main/java/com/xiaomi/xmsf/push/utils/ConfigurationsLoader.java +++ b/push/src/main/java/com/xiaomi/xmsf/push/utils/ConfigurationsLoader.java @@ -21,6 +21,7 @@ import java.io.InputStreamReader; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; @@ -35,8 +36,8 @@ public class ConfigurationsLoader { private static final Logger logger = XLog.tag(ConfigurationsLoader.class.getSimpleName()).build(); - private String version; - private Map> packageConfigs = new HashMap<>(); + private volatile PublishedConfigurationState publishedState = + PublishedConfigurationState.notConfigured(); private Context mContext = null; private Uri mTreeUri = null; @@ -48,21 +49,93 @@ public ConfigurationsLoader() { } public Map> getConfigs() { - return packageConfigs; + return publishedState.packageConfigs; } - public boolean init(Context context, Uri treeUri) { + public ConfigurationDiagnosticsSnapshot getDiagnosticsSnapshot() { + return publishedState.diagnosticsSnapshot; + } + + /** + * Returns a deterministic snapshot of configuration references that do not resolve to a + * currently loaded config key. Diagnostics contain config metadata only; notification data is + * never inspected or retained. + */ + public List getUnresolvedReferences() { + return getDiagnosticsSnapshot().getUnresolvedReferences(); + } + + public synchronized boolean init(Context context, Uri treeUri) { mLastLoadTime = System.currentTimeMillis(); - packageConfigs.clear(); - do { - if (context == null || treeUri == null) { - break; + if (context == null || treeUri == null) { + clearDirectoryTracking(); + publishedState = PublishedConfigurationState.notConfigured(); + return false; + } + + mContext = context; + mTreeUri = treeUri; + mDocumentFile = null; + + DocumentFile documentFile; + try { + documentFile = DocumentFile.fromTreeUri(context, treeUri); + if (documentFile == null + || !documentFile.exists() + || !documentFile.isDirectory() + || !documentFile.canRead()) { + logger.e("configuration_directory_load_failed stage=[validate_directory]"); + publishedState = PublishedConfigurationState.failed(); + return false; } - List> exceptions = new ArrayList<>(); - List loadedFiles = new ArrayList<>(); - parseDirectory(context, treeUri, exceptions, loadedFiles); + } catch (RuntimeException exception) { + logDirectoryFailure("open_directory", exception); + publishedState = PublishedConfigurationState.failed(); + return false; + } - if (!loadedFiles.isEmpty() && Global.ConfigCenter().isShowConfigurationListOnLoaded(context)) { + mDocumentFile = documentFile; + MutableConfigurationState candidate = MutableConfigurationState.empty(); + List> exceptions = new ArrayList<>(); + List loadedFiles = new ArrayList<>(); + boolean directoryParsed; + try { + directoryParsed = parseDirectory( + context, treeUri, documentFile, candidate, exceptions, loadedFiles); + } catch (RuntimeException exception) { + logDirectoryFailure("parse_directory", exception); + directoryParsed = false; + } + + boolean successful = directoryParsed && exceptions.isEmpty(); + if (successful) { + publish(candidate); + } else { + publishedState = PublishedConfigurationState.failed(); + } + + reportDirectoryLoadResult(context, loadedFiles, exceptions); + return successful; + } + + private void clearDirectoryTracking() { + mContext = null; + mTreeUri = null; + mDocumentFile = null; + } + + private static void logDirectoryFailure(String stage, RuntimeException exception) { + logger.e("configuration_directory_load_failed stage=[%s] exception_type=[%s]", + stage, exception.getClass().getName()); + } + + private static void reportDirectoryLoadResult( + Context context, + List loadedFiles, + List> exceptions) { + try { + if (!loadedFiles.isEmpty() + && Global.ConfigCenter().isShowConfigurationListOnLoaded(context)) { StringBuilder loadedList = new StringBuilder("loaded configuration list:"); for (DocumentFile file : loadedFiles) { loadedList.append('\n'); @@ -70,17 +143,18 @@ public boolean init(Context context, Uri treeUri) { } Utils.makeText(context, loadedList, Toast.LENGTH_SHORT); } - if (!exceptions.isEmpty()) { - for (Pair pair : exceptions) { - StringBuilder errmsg = getJsonExceptionMessage(context, pair); - logger.e(errmsg); - Utils.makeText(context, errmsg.toString(), Toast.LENGTH_LONG); - } - break; + } catch (RuntimeException exception) { + logDirectoryFailure("report_loaded_files", exception); + } + for (Pair pair : exceptions) { + try { + StringBuilder errmsg = getJsonExceptionMessage(context, pair); + logger.e(errmsg); + Utils.makeText(context, errmsg.toString(), Toast.LENGTH_LONG); + } catch (RuntimeException exception) { + logDirectoryFailure("report_json_error", exception); } - return true; - } while (false); - return false; + } } @NonNull @@ -118,45 +192,138 @@ public static StringBuilder getJsonExceptionMessage(Context context, Pair> exceptions, List loadedFiles) { - DocumentFile documentFile = DocumentFile.fromTreeUri(context, treeUri); - if (documentFile == null) { - return true; - } - mContext = context; - mTreeUri = treeUri; - mDocumentFile = documentFile; + private boolean parseDirectory( + Context context, + Uri treeUri, + DocumentFile documentFile, + MutableConfigurationState candidate, + List> exceptions, + List loadedFiles) { logger.i("parseDirectory uri: [%s]", treeUri.getPath()); - DocumentFile[] files = documentFile.listFiles(); + DocumentFile[] files; + try { + files = documentFile.listFiles(); + } catch (RuntimeException exception) { + logDirectoryFailure("list_files", exception); + return false; + } + if (files == null) { + logger.e("configuration_directory_load_failed stage=[list_files_null]"); + return false; + } for (DocumentFile file : files) { - logger.i("file: [%s], type: [%s]", file.getName(), file.getType()); - if (!file.getName().toLowerCase().endsWith(".json")) { + String fileName = file.getName(); + logger.i("file: [%s], type: [%s]", fileName, file.getType()); + if (fileName == null || !fileName.toLowerCase().endsWith(".json")) { continue; } - String json = readTextFromUri(context, file.getUri()); + String json; + try { + json = readConfigurationText(context, file.getUri()); + } catch (ConfigurationReadException exception) { + logger.e( + "configuration_file_load_failed file=[%s] exception_type=[%s]", + fileName, exception.exceptionType); + return false; + } try { - parse(json); + parse(json, fileName, candidate); loadedFiles.add(file); } catch (JSONException e) { exceptions.add(new Pair<>(file, e)); } } - return false; + return exceptions.isEmpty(); + } + + private static String readConfigurationText(Context context, Uri uri) + throws ConfigurationReadException { + StringBuilder stringBuilder = new StringBuilder(); + try (InputStream inputStream = context.getContentResolver().openInputStream(uri); + BufferedReader reader = new BufferedReader( + new InputStreamReader(Objects.requireNonNull(inputStream)))) { + char[] buffer = new char[1024]; + int len; + while ((len = reader.read(buffer)) != -1) { + stringBuilder.append(buffer, 0, len); + } + return stringBuilder.toString(); + } catch (Exception exception) { + exception.printStackTrace(); + Utils.makeText(context, exception.toString(), Toast.LENGTH_LONG); + throw new ConfigurationReadException(exception.getClass().getName()); + } + } + + private static final class ConfigurationReadException extends Exception { + private final String exceptionType; + + private ConfigurationReadException(String exceptionType) { + super(null, null, false, false); + this.exceptionType = exceptionType; + } } public void load(String json) throws JSONException { - parse(json); + load("", json); } - private void parse(String json) throws JSONException { + /** Loads an in-memory configuration while retaining a caller-provided diagnostic source name. */ + public synchronized void load(String sourceName, String json) throws JSONException { + MutableConfigurationState candidate = + MutableConfigurationState.copyOf(publishedState); + parse(json, sourceName, candidate); + publish(candidate); + } + + private void parse( + String json, String sourceName, MutableConfigurationState candidate) + throws JSONException { JSONObject jsonObject = new JSONObject(json); - version = jsonObject.getString("version"); + candidate.version = jsonObject.getString("version"); JSONObject packageConfigsObj = jsonObject.getJSONObject("configs"); Iterator packageNames = packageConfigsObj.keys(); while (packageNames.hasNext()) { String packageName = packageNames.next(); JSONArray configsObj = packageConfigsObj.getJSONArray(packageName); - packageConfigs.put(packageName, parseConfigs(configsObj)); + candidate.packageConfigs.put(packageName, parseConfigs(configsObj)); + candidate.referenceSites.put( + packageName, findReferenceSites(sourceName, packageName, configsObj)); + } + } + + @NonNull + private static List findReferenceSites( + String sourceName, String ownerKey, JSONArray configsObj) throws JSONException { + List sites = new ArrayList<>(); + for (int i = 0; i < configsObj.length(); ++i) { + Object config = configsObj.get(i); + if (config instanceof String) { + sites.add(ConfigurationReferenceDiagnostics.referenceSite( + sourceName, ownerKey, (String) config)); + } + } + return sites; + } + + private void publish(MutableConfigurationState candidate) { + List sites = new ArrayList<>(); + for (List ownerSites + : candidate.referenceSites.values()) { + sites.addAll(ownerSites); + } + List unresolvedReferences = + ConfigurationReferenceDiagnostics.resolve( + candidate.packageConfigs.keySet(), sites); + ConfigurationDiagnosticsSnapshot diagnosticsSnapshot = + ConfigurationDiagnosticsSnapshot.ready(unresolvedReferences); + publishedState = PublishedConfigurationState.ready( + candidate.version, candidate.packageConfigs, candidate.referenceSites, + diagnosticsSnapshot); + for (ConfigurationReferenceDiagnostics.UnresolvedReference diagnostic + : unresolvedReferences) { + logger.w("unresolved_configuration_reference source=[%s] owner=[%s] reference=[%s]", + diagnostic.getSourceName(), diagnostic.getOwnerKey(), diagnostic.getReference()); } } @@ -205,7 +372,7 @@ PackageConfig parseConfig(JSONObject configObj) throws JSONException { return config; } - public void reInitIfDirectoryUpdated() { + public synchronized void reInitIfDirectoryUpdated() { if (mContext == null || mTreeUri == null || mDocumentFile == null) { return; } @@ -214,6 +381,102 @@ public void reInitIfDirectoryUpdated() { } } + private static final class MutableConfigurationState { + private String version; + private final Map> packageConfigs; + private final Map> + referenceSites; + + private MutableConfigurationState( + String version, + Map> packageConfigs, + Map> + referenceSites) { + this.version = version; + this.packageConfigs = packageConfigs; + this.referenceSites = referenceSites; + } + + private static MutableConfigurationState empty() { + return new MutableConfigurationState(null, new HashMap<>(), new HashMap<>()); + } + + private static MutableConfigurationState copyOf(PublishedConfigurationState state) { + return new MutableConfigurationState( + state.version, + new HashMap<>(state.packageConfigs), + new HashMap<>(state.referenceSites)); + } + } + + private static final class PublishedConfigurationState { + private final String version; + private final Map> packageConfigs; + private final Map> + referenceSites; + private final ConfigurationDiagnosticsSnapshot diagnosticsSnapshot; + + private PublishedConfigurationState( + String version, + Map> packageConfigs, + Map> + referenceSites, + ConfigurationDiagnosticsSnapshot diagnosticsSnapshot) { + this.version = version; + this.packageConfigs = immutableConfigMap(packageConfigs); + this.referenceSites = immutableReferenceMap(referenceSites); + this.diagnosticsSnapshot = diagnosticsSnapshot; + } + + private static Map> immutableConfigMap( + Map> source) { + Map> copy = new HashMap<>(); + for (Map.Entry> entry : source.entrySet()) { + copy.put(entry.getKey(), Collections.unmodifiableList( + new ArrayList<>(entry.getValue()))); + } + return Collections.unmodifiableMap(copy); + } + + private static Map> + immutableReferenceMap( + Map> source) { + Map> copy = + new HashMap<>(); + for (Map.Entry> entry + : source.entrySet()) { + copy.put(entry.getKey(), Collections.unmodifiableList( + new ArrayList<>(entry.getValue()))); + } + return Collections.unmodifiableMap(copy); + } + + private static PublishedConfigurationState notConfigured() { + return empty(ConfigurationDiagnosticsSnapshot.notConfigured()); + } + + private static PublishedConfigurationState failed() { + return empty(ConfigurationDiagnosticsSnapshot.failed()); + } + + private static PublishedConfigurationState empty( + ConfigurationDiagnosticsSnapshot diagnosticsSnapshot) { + return new PublishedConfigurationState( + null, Collections.emptyMap(), Collections.emptyMap(), diagnosticsSnapshot); + } + + private static PublishedConfigurationState ready( + String version, + Map> packageConfigs, + Map> + referenceSites, + ConfigurationDiagnosticsSnapshot diagnosticsSnapshot) { + return new PublishedConfigurationState( + version, packageConfigs, referenceSites, diagnosticsSnapshot); + } + } + public static String readTextFromUri(Context context, Uri uri) { StringBuilder stringBuilder = new StringBuilder(); diff --git a/push/src/main/java/com/xiaomi/xmsf/utils/ConfigCenter.java b/push/src/main/java/com/xiaomi/xmsf/utils/ConfigCenter.java index e4d061861..0b83afa25 100644 --- a/push/src/main/java/com/xiaomi/xmsf/utils/ConfigCenter.java +++ b/push/src/main/java/com/xiaomi/xmsf/utils/ConfigCenter.java @@ -21,6 +21,9 @@ */ public class ConfigCenter { + public static final String KEY_START_FOREGROUND_SERVICE = "StartForegroundService"; + public static final String KEY_FLOATING_BOTTOM_NAVIGATION = "FloatingBottomNavigation"; + public ConfigCenter() { } @@ -76,7 +79,25 @@ public boolean isShowAllEvents() { } public boolean isStartForegroundService() { - return getSharedPreferences(Utils.getApplication()).getBoolean("StartForegroundService", false); + return isStartForegroundService(Utils.getApplication()); + } + + public boolean isStartForegroundService(Context ctx) { + return getSharedPreferences(ctx).getBoolean(KEY_START_FOREGROUND_SERVICE, false); + } + + public boolean isFloatingBottomNavigation(Context ctx) { + return getSharedPreferences(ctx).getBoolean(KEY_FLOATING_BOTTOM_NAVIGATION, true); + } + + public boolean isFloatingBottomNavigation() { + return isFloatingBottomNavigation(Utils.getApplication()); + } + + public boolean setFloatingBottomNavigation(Context ctx, boolean enabled) { + return getSharedPreferences(ctx).edit() + .putBoolean(KEY_FLOATING_BOTTOM_NAVIGATION, enabled) + .commit(); } public void loadConfigurations(Context context) { diff --git a/push/src/main/java/com/xiaomi/xmsf/utils/ConvertUtils.java b/push/src/main/java/com/xiaomi/xmsf/utils/ConvertUtils.java index 6672d9d9a..32946cdd2 100644 --- a/push/src/main/java/com/xiaomi/xmsf/utils/ConvertUtils.java +++ b/push/src/main/java/com/xiaomi/xmsf/utils/ConvertUtils.java @@ -57,6 +57,23 @@ public boolean shouldSkipField(FieldAttributes f) { @Override public boolean shouldSkipClass(Class clazz) { + // 1. 排除所有 Android Context 和 View 相关的类 + if (clazz.getName().startsWith("android.content.Context") || + clazz.getName().startsWith("android.view.") || + clazz.getName().startsWith("android.app.")) { + return true; + } + // 2. 排除所有与 Binder/IPC 相关的类,它们通常导致循环 + if (clazz.getName().startsWith("android.os.IBinder") || + clazz.getName().startsWith("android.os.Parcelable$Creator")) { + return true; + } + // 3. 排除所有线程相关的类,如 Looper/Handler + if (clazz.getName().startsWith("android.os.Handler") || + clazz.getName().startsWith("android.os.Looper")) { + return true; + } + return false; } }) @@ -82,9 +99,30 @@ public static JsonElement toJson(Intent intent) { if (intent == null) { return JsonNull.INSTANCE; } + // 在 toJson(Intent intent) 方法中,修改 GsonBuilder: Gson gson = new GsonBuilder() - .registerTypeAdapterFactory(new BundleTypeAdapterFactory()) - .create(); + .registerTypeAdapterFactory(new BundleTypeAdapterFactory()) + // 添加新的 ExclusionStrategy + .setExclusionStrategies(new ExclusionStrategy() { + @Override + public boolean shouldSkipField(FieldAttributes f) { + // 排除 Intent 内部可能引起问题的字段,例如 mPackage + if (f.getName().equals("mContext") || f.getName().equals("mIBinder")) { + return true; + } + return false; + } + + @Override + public boolean shouldSkipClass(Class clazz) { + // 排除 Intent 对象本身 (如果被外部调用序列化 Intent 时) + if (clazz.equals(Intent.class)) { + return true; + } + return false; + } + }) + .create(); JsonObject json = new JsonObject(); json.add("action", gson.toJsonTree(intent.getAction())); if (intent.getExtras() != null) { diff --git a/push/src/main/java/com/xiaomi/xmsf/utils/LogUtils.java b/push/src/main/java/com/xiaomi/xmsf/utils/LogUtils.java index de20b16a0..5ee8f7ec4 100644 --- a/push/src/main/java/com/xiaomi/xmsf/utils/LogUtils.java +++ b/push/src/main/java/com/xiaomi/xmsf/utils/LogUtils.java @@ -32,8 +32,6 @@ import java.util.Date; import java.util.Locale; -import top.trumeet.common.Constants; - /** * Created by Trumeet on 2017/8/28. * @@ -92,7 +90,7 @@ public static Intent getShareIntent(Context context) { zipFile.getAbsolutePath()); Uri fileUri = FileProvider.getUriForFile( context, - Constants.AUTHORITY_FILE_PROVIDER, + context.getPackageName() + ".fileprovider", zipFile); if (fileUri == null || !zipFile.exists()) { throw new NullPointerException(); diff --git a/push/src/main/java/top/trumeet/mipush/provider/db/EventDb.java b/push/src/main/java/top/trumeet/mipush/provider/db/EventDb.java index 403656e7c..0cdb51df9 100644 --- a/push/src/main/java/top/trumeet/mipush/provider/db/EventDb.java +++ b/push/src/main/java/top/trumeet/mipush/provider/db/EventDb.java @@ -11,14 +11,15 @@ import com.nihility.XMPushUtils; import com.xiaomi.xmpush.thrift.XmPushActionContainer; import com.xiaomi.xmpush.thrift.XmPushActionRegistrationResult; -import com.xiaomi.xmsf.push.utils.RegSecUtils; import com.xiaomi.xmsf.utils.ConvertUtils; +import org.greenrobot.greendao.query.LazyList; import org.greenrobot.greendao.query.QueryBuilder; -import org.greenrobot.greendao.query.WhereCondition; import java.util.HashSet; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Set; import top.trumeet.common.utils.DatabaseUtils; @@ -44,6 +45,7 @@ private static DatabaseUtils getInstance(Context context) { public static class RegistrationInfo { public Set registered = new HashSet<>(); public Set unregistered = new HashSet<>(); + public Map latestControlEvidenceTime = new HashMap<>(); } public static long insertEvent(Event event) { @@ -129,27 +131,53 @@ public static void deleteHistory() { public static RegistrationInfo queryRegistered() { QueryBuilder query = daoSession.queryBuilder(Event.class) - .where(EventDao.Properties.Type.in(Event.Type.RegistrationResult, Event.Type.UnRegistration)) - .where(new WhereCondition.StringCondition("1" + - " GROUP BY " + EventDao.Properties.Pkg.columnName + - " HAVING MAX(" + EventDao.Properties.Date.columnName + ")")); - List events = query.list(); + .where(EventDao.Properties.Type.in( + Event.Type.RegistrationResult, + Event.Type.UnRegistration)) + .orderDesc(EventDao.Properties.Date, EventDao.Properties.Id); RegistrationInfo info = new RegistrationInfo(); - for (Event event : events) { - XmPushActionContainer container = XMPushUtils.packToContainer(event.getPayload()); - XmPushActionRegistrationResult data = null; - try { - data = (XmPushActionRegistrationResult) - ConvertUtils.getResponseMessageBodyFromContainer(container, - RegSecUtils.getRegSec(container)); - } catch (Exception ignored) { - } - if (event.getType() == Event.Type.RegistrationResult && (data == null || data.errorCode == 0)) { - info.registered.add(event.getPkg()); - } else { - info.unregistered.add(event.getPkg()); + Set packagesWithNewerEvidence = new HashSet<>(); + LazyList events = query.listLazyUncached(); + try { + for (Event event : events) { + String packageName = event.getPkg(); + if (packageName == null || packageName.isEmpty() + || packagesWithNewerEvidence.contains(packageName)) { + continue; + } + + XmPushActionRegistrationResult registrationResult = null; + if (event.getType() == Event.Type.RegistrationResult) { + try { + XmPushActionContainer container = + XMPushUtils.packToContainer(event.getPayload()); + registrationResult = (XmPushActionRegistrationResult) + ConvertUtils.getResponseMessageBodyFromContainer( + container, event.getRegSec()); + } catch (Throwable ignored) { + // An undecodable response supplies no evidence and is never treated as + // a successful registration. + } + } + + RegistrationEvidenceResolver.EventEvidence evidence = + RegistrationEvidenceResolver.classifyEvent( + event.getType(), registrationResult); + if (evidence == RegistrationEvidenceResolver.EventEvidence.UNKNOWN) { + // UNKNOWN is not evidence and must not hide an older, decodable control event. + continue; + } + packagesWithNewerEvidence.add(packageName); + info.latestControlEvidenceTime.put(packageName, event.getDate()); + if (evidence == RegistrationEvidenceResolver.EventEvidence.POSITIVE) { + info.registered.add(packageName); + } else { + info.unregistered.add(packageName); + } } + } finally { + events.close(); } return info; } diff --git a/push/src/main/java/top/trumeet/mipush/provider/db/RegisteredApplicationDb.java b/push/src/main/java/top/trumeet/mipush/provider/db/RegisteredApplicationDb.java index 7f132744e..20e18ef7e 100644 --- a/push/src/main/java/top/trumeet/mipush/provider/db/RegisteredApplicationDb.java +++ b/push/src/main/java/top/trumeet/mipush/provider/db/RegisteredApplicationDb.java @@ -29,7 +29,18 @@ public class RegisteredApplicationDb { public static RegisteredApplication registerApplication(String pkg) { RegisteredApplication registeredApplication = getRegisteredApplication(pkg); if (registeredApplication == null) { - return create(pkg); + try { + return create(pkg); + } catch (RuntimeException insertFailure) { + // A registration event can race the application-list refresh. If the competing + // insert won the unique package constraint, reuse that row; otherwise preserve + // the original database failure. + registeredApplication = getRegisteredApplication(pkg); + if (registeredApplication != null) { + return registeredApplication; + } + throw insertFailure; + } } return registeredApplication; } diff --git a/push/src/main/java/top/trumeet/mipush/provider/db/RegistrationEvidenceResolver.java b/push/src/main/java/top/trumeet/mipush/provider/db/RegistrationEvidenceResolver.java new file mode 100644 index 000000000..dfee1a77e --- /dev/null +++ b/push/src/main/java/top/trumeet/mipush/provider/db/RegistrationEvidenceResolver.java @@ -0,0 +1,91 @@ +package top.trumeet.mipush.provider.db; + +import androidx.annotation.Nullable; + +import com.xiaomi.xmpush.thrift.XmPushActionRegistrationResult; + +import java.util.HashSet; +import java.util.Set; + +import top.trumeet.mipush.provider.entities.Event; +import top.trumeet.mipush.provider.entities.RegisteredApplication; + +/** Resolves registration state from protocol evidence without package-name rules. */ +public final class RegistrationEvidenceResolver { + private RegistrationEvidenceResolver() { + } + + public enum EventEvidence { + POSITIVE, + NEGATIVE, + UNKNOWN + } + + /** + * A decoded successful registration repairs stale SDK state first. The persisted + * unregistered flag then rejects late/in-flight messages before receive evidence or the + * active registry are considered; older negative control evidence is the final fallback. + */ + public static @RegisteredApplication.RegisteredType int resolve( + boolean explicitlyUnregistered, + boolean activeRegistryContainsPackage, + boolean latestControlEventIsPositive, + boolean newerReceiveEvidence, + boolean latestControlEventIsNegative) { + if (latestControlEventIsPositive) { + return RegisteredApplication.RegisteredType.Registered; + } + if (explicitlyUnregistered) { + return RegisteredApplication.RegisteredType.Unregistered; + } + if (newerReceiveEvidence) { + return RegisteredApplication.RegisteredType.Registered; + } + if (activeRegistryContainsPackage) { + return RegisteredApplication.RegisteredType.Registered; + } + if (latestControlEventIsNegative) { + return RegisteredApplication.RegisteredType.Unregistered; + } + return RegisteredApplication.RegisteredType.NotRegistered; + } + + /** A registration result only counts when decoded with an explicit error code. */ + public static EventEvidence classifyEvent( + @Event.Type int eventType, + @Nullable XmPushActionRegistrationResult registrationResult) { + if (eventType == Event.Type.UnRegistration) { + return EventEvidence.NEGATIVE; + } + if (eventType != Event.Type.RegistrationResult + || registrationResult == null + || !registrationResult.isSetErrorCode()) { + return EventEvidence.UNKNOWN; + } + return registrationResult.getErrorCode() == 0 + ? EventEvidence.POSITIVE : EventEvidence.NEGATIVE; + } + + /** A delivered message supersedes older control evidence, but not an equal-time event. */ + public static boolean isReceiveEvidenceNewer( + long lastReceiveTime, @Nullable Long latestControlEvidenceTime) { + return lastReceiveTime > 0 + && (latestControlEvidenceTime == null + || lastReceiveTime > latestControlEvidenceTime); + } + + /** Parse the SDK's persisted comma-separated package set without relying on its cache. */ + public static Set parsePersistedPackageSet(@Nullable String persistedPackages) { + Set packages = new HashSet<>(); + if (persistedPackages == null || persistedPackages.trim().isEmpty()) { + return packages; + } + for (String candidate : persistedPackages.split(",")) { + String packageName = candidate.trim(); + if (!packageName.isEmpty()) { + packages.add(packageName); + } + } + return packages; + } +} diff --git a/push/src/main/java/top/trumeet/mipush/provider/entities/RegisteredApplication.java b/push/src/main/java/top/trumeet/mipush/provider/entities/RegisteredApplication.java index 5610c3f33..7aecea213 100644 --- a/push/src/main/java/top/trumeet/mipush/provider/entities/RegisteredApplication.java +++ b/push/src/main/java/top/trumeet/mipush/provider/entities/RegisteredApplication.java @@ -113,11 +113,48 @@ public void writeToParcel(Parcel parcel, int i) { int Unregistered = 2; } + /** + * Result of the best-effort MiPush manifest service probe. + * + *

This is deliberately transient: service visibility is a property of the current + * PackageManager/ROM and must not be persisted with a registration record. MISSING means + * the checker ran and found the required SDK services absent or invalid; UNKNOWN means the + * checker could not run or the package metadata was not visible to us.

+ */ + public enum ServiceProbeState { + UNKNOWN, + PRESENT, + MISSING + } + @RegisteredType private int registeredType = RegisteredType.NotRegistered; @Transient public boolean existServices = false; + /** + * New tri-state service probe result. The MISSING default preserves the historical meaning + * of {@link #existServices} for callers that construct an entity manually and only set the + * legacy boolean. ApplicationPageOperation always assigns an explicit probe result. + */ + @Transient + public ServiceProbeState serviceProbeState = ServiceProbeState.MISSING; + + /** + * Resolve the tri-state value while honoring legacy callers that only set + * {@link #existServices}. A positive legacy value is unambiguous; false retains the + * historical MISSING default unless the caller explicitly assigns UNKNOWN. + */ + public ServiceProbeState getServiceProbeState() { + if (serviceProbeState == ServiceProbeState.MISSING && existServices) { + return ServiceProbeState.PRESENT; + } + return serviceProbeState; + } + + public void setServiceProbeState(ServiceProbeState state) { + serviceProbeState = state == null ? ServiceProbeState.UNKNOWN : state; + } public String appName = ""; @Transient public String appNamePinYin = ""; diff --git a/push/src/main/java/top/trumeet/mipushframework/MainPageUtils.java b/push/src/main/java/top/trumeet/mipushframework/MainPageUtils.java index c60663aef..83f98a9af 100644 --- a/push/src/main/java/top/trumeet/mipushframework/MainPageUtils.java +++ b/push/src/main/java/top/trumeet/mipushframework/MainPageUtils.java @@ -13,9 +13,9 @@ import com.xiaomi.push.service.XMPushServiceMessenger; import com.xiaomi.smack.ConnectionConfiguration; -public class MainPageUtils { +public class MainPageUtils implements AutoCloseable { private static final String TAG = MainPageUtils.class.getSimpleName(); - InternalMessenger messenger; + private InternalMessenger messenger; public interface ConnectionStatusChanged { void onChange(XMPushServiceListener.ConnectionStatus status); @@ -24,13 +24,21 @@ public interface ConnectionStatusChanged { public MainPageUtils() { } - public void initOnCreate(Context context, ConnectionStatusChanged connectionStatusChanged) { + public synchronized void initOnCreate(Context context, ConnectionStatusChanged connectionStatusChanged) { context = context.getApplicationContext(); + if (messenger != null) { + messenger.close(); + } messenger = new InternalMessenger(context) {{ register(new IntentFilter(XMPushServiceMessenger.IntentSetConnectionStatus)); addListener(intent -> { String status = intent.getStringExtra("status"); - connectionStatusChanged.onChange(XMPushServiceListener.ConnectionStatus.valueOf(status)); + if (status != null && connectionStatusChanged != null) { + try { + connectionStatusChanged.onChange(XMPushServiceListener.ConnectionStatus.valueOf(status)); + } catch (Throwable ignored) { + } + } }); }}; @@ -41,6 +49,14 @@ public void initOnCreate(Context context, ConnectionStatusChanged connectionStat messenger.send(new Intent(XMPushServiceMessenger.IntentGetConnectionStatus)); } + @Override + public synchronized void close() { + if (messenger != null) { + messenger.close(); + messenger = null; + } + } + void printHookResultForCheck() { Log.i(TAG, String.format("[hook_res] MIUIUtils.getIsMIUI() -> [%s]", MIUIUtils.getIsMIUI())); Log.i(TAG, String.format("[hook_res] DeviceInfo.quicklyGetIMEI() -> [%s]", DeviceInfo.quicklyGetIMEI(null))); diff --git a/push/src/main/java/top/trumeet/mipushframework/component/AppIcon.kt b/push/src/main/java/top/trumeet/mipushframework/component/AppIcon.kt index 771717ff9..9b7b7c75f 100644 --- a/push/src/main/java/top/trumeet/mipushframework/component/AppIcon.kt +++ b/push/src/main/java/top/trumeet/mipushframework/component/AppIcon.kt @@ -10,6 +10,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalInspectionMode import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -24,6 +25,19 @@ fun initIconCache(context: Context) { } } +/** Called by the process lifecycle to release UI-only decoded artwork. */ +fun trimIconCache(level: Int) { + if (::iconCache.isInitialized) { + iconCache.trimMemory(level) + } +} + +fun clearIconCache() { + if (::iconCache.isInitialized) { + iconCache.clearMemory() + } +} + @Composable fun AppIcon(packageName: String, appName: String?, modifier: Modifier = Modifier) { val isPreview = LocalInspectionMode.current @@ -40,5 +54,5 @@ fun AppIcon(packageName: String, appName: String?, modifier: Modifier = Modifier } } } - Image(icon, appName, modifier = modifier) -} \ No newline at end of file + Image(icon, appName, modifier = modifier, contentScale = ContentScale.Fit) +} diff --git a/push/src/main/java/top/trumeet/mipushframework/component/MiuixCompat.kt b/push/src/main/java/top/trumeet/mipushframework/component/MiuixCompat.kt new file mode 100644 index 000000000..98210ced6 --- /dev/null +++ b/push/src/main/java/top/trumeet/mipushframework/component/MiuixCompat.kt @@ -0,0 +1,392 @@ +package top.trumeet.mipushframework.component + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.EaseOut +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.gestures.rememberDraggableState +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredWidth +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import top.yukonga.miuix.kmp.basic.Icon +import top.yukonga.miuix.kmp.basic.IconButton +import top.yukonga.miuix.kmp.basic.NavigationBar +import top.yukonga.miuix.kmp.basic.NavigationItem +import top.yukonga.miuix.kmp.basic.Scaffold +import top.yukonga.miuix.kmp.basic.Surface +import top.yukonga.miuix.kmp.basic.Text +import top.yukonga.miuix.kmp.basic.TextField +import top.yukonga.miuix.kmp.extra.SuperDialog +import top.yukonga.miuix.kmp.theme.MiuixTheme +import top.yukonga.miuix.kmp.utils.MiuixPopupUtil.Companion.dismissDialog +import top.yukonga.miuix.kmp.utils.SmoothRoundedCornerShape +import kotlin.math.abs +import kotlin.math.roundToInt +import kotlin.math.sign + +/** + * Small page-level adapters for the Miuix API. + * + * Miuix intentionally exposes a compact API (for example, [Button] takes a string instead of a + * slot). Keeping these adapters local to the app lets the existing pages retain their action + * slots while ensuring that the rendered controls are still genuine Miuix controls. + */ +@Composable +fun MiuixPageScaffold( + modifier: Modifier = Modifier, + bottomBar: @Composable () -> Unit = {}, + content: @Composable (PaddingValues) -> Unit, +) { + Scaffold( + modifier = modifier, + bottomBar = bottomBar, + containerColor = MiuixTheme.colorScheme.background, + content = content, + ) +} + +@Composable +fun MiuixActionButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + content: @Composable RowScope.() -> Unit, +) { + // Surface is the slot-capable primitive used by Miuix Button internally. The transparent + // variant is appropriate for dialog/text actions and keeps the original action semantics. + Surface( + modifier = modifier, + onClick = onClick, + enabled = enabled, + shape = SmoothRoundedCornerShape(16.dp), + color = Color.Transparent, + ) { + Row( + modifier = Modifier + .defaultMinSize(minWidth = 58.dp, minHeight = 40.dp) + .padding(horizontal = 12.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + content = content, + ) + } +} + +@Composable +fun MiuixActionIconButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + content: @Composable () -> Unit, +) { + IconButton( + onClick = onClick, + modifier = modifier, + enabled = enabled, + backgroundColor = Color.Transparent, + content = content, + ) +} + +@Composable +fun MiuixDialog( + title: String, + show: MutableState, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + SuperDialog( + modifier = modifier, + title = title, + show = show, + onDismissRequest = { + dismissDialog(show) + onDismiss() + }, + content = content, + ) +} + +@Composable +fun MiuixInput( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + label: String = "", + enabled: Boolean = true, + singleLine: Boolean = false, +) { + TextField( + value = value, + onValueChange = onValueChange, + modifier = modifier, + label = label, + enabled = enabled, + singleLine = singleLine, + ) +} + +@Composable +fun MiuixBottomNavigation( + items: List, + selected: Int, + onClick: (Int) -> Unit, + modifier: Modifier = Modifier, + floating: Boolean = true, +) { + if (floating) { + require(items.size in 2..5) { "BottomBar must have between 2 and 5 items" } + val tabWidth = 76.dp + val density = LocalDensity.current + val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr + val tabWidthPx = with(density) { tabWidth.toPx() } + val rubberBandLimitPx = with(density) { 4.dp.toPx() } + val totalWidthPx = tabWidthPx * items.size + with(density) { 8.dp.toPx() } + val lastIndex = items.lastIndex.toFloat() + val selectedIndex = selected.coerceIn(items.indices) + val indicatorPosition = remember(items.size) { + Animatable(selectedIndex.toFloat(), visibilityThreshold = 0.001f) + } + var isDragging by remember { mutableStateOf(false) } + var dragPosition by remember { mutableFloatStateOf(selectedIndex.toFloat()) } + var panelDragOffsetPx by remember { mutableFloatStateOf(0f) } + var rubberBandOffsetPx by remember { mutableFloatStateOf(0f) } + + val itemInteractionSources = remember(items.size) { + List(items.size) { MutableInteractionSource() } + } + val selectedPressed by itemInteractionSources[selectedIndex].collectIsPressedAsState() + val pressProgress by animateFloatAsState( + targetValue = if (isDragging || selectedPressed) 1f else 0f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium, + ), + label = "MiuixBottomNavigationPress", + ) + val panelOffsetPx by animateFloatAsState( + targetValue = if (isDragging) rubberBandOffsetPx else 0f, + animationSpec = spring( + dampingRatio = 0.5f, + stiffness = 300f, + ), + label = "MiuixBottomNavigationRubberBand", + ) + + LaunchedEffect(selectedIndex) { + if (!isDragging) { + indicatorPosition.animateTo( + targetValue = selectedIndex.toFloat(), + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + visibilityThreshold = 0.001f, + ), + ) + } + } + + val dragState = rememberDraggableState { deltaPx -> + val logicalDelta = deltaPx / tabWidthPx * if (isLtr) 1f else -1f + // KernelSU bases each update on the current bounded target. This lets the indicator + // leave an edge immediately when the drag reverses, while the panel keeps a small + // independent rubber-band displacement and springs home on release. + dragPosition = (dragPosition + logicalDelta).coerceIn(0f, lastIndex) + panelDragOffsetPx += deltaPx + val dragFraction = (panelDragOffsetPx / totalWidthPx).coerceIn(-1f, 1f) + rubberBandOffsetPx = rubberBandLimitPx * dragFraction.sign * + EaseOut.transform(abs(dragFraction)) + } + + val displayedPosition = if (isDragging) dragPosition else indicatorPosition.value + val indicatorTranslationPx = displayedPosition * tabWidthPx * if (isLtr) 1f else -1f + // Keep the pressed indicator inside the outer pill's 4 dp inset at the edge tabs. + val indicatorScaleX = 1f + 0.10f * pressProgress + val indicatorScaleY = 1f - 0.04f * pressProgress + + // Interaction and geometry reference: KernelSU Manager's FloatingBottomBar and + // BottomBarMiuix (GPL-3.0), independently reimplemented here with the Miuix 0.2.x API: + // https://github.com/tiann/KernelSU/tree/main/manager/app/src/main/java/me/weishu/kernelsu/ui/component + // This file does not copy KernelSU source, assets, or its blur dependencies. + // The compatibility path preserves the reference's 64/4/56/76dp geometry, draggable + // indicator, RTL-aware motion and edge resistance without importing the newer blur stack. + val panelWidth = tabWidth * items.size + 8.dp + Surface( + modifier = modifier + // A floating panel must own its intrinsic width. `wrapContentWidth()` preserves + // a full-width parent constraint, which makes the Surface paint an opaque strip + // across the whole bottom row when this component is placed in an overlay. + .requiredWidth(panelWidth) + .graphicsLayer { translationX = panelOffsetPx }, + shape = SmoothRoundedCornerShape(32.dp), + color = MiuixTheme.colorScheme.surfaceContainer, + shadowElevation = 1f, + ) { + Box( + modifier = Modifier + .height(64.dp) + .width(panelWidth) + .padding(4.dp), + contentAlignment = Alignment.CenterStart, + ) { + Surface( + modifier = Modifier + .graphicsLayer { + translationX = indicatorTranslationPx + scaleX = indicatorScaleX + scaleY = indicatorScaleY + transformOrigin = TransformOrigin.Center + } + .width(tabWidth) + .height(56.dp), + shape = SmoothRoundedCornerShape(28.dp), + color = MiuixTheme.colorScheme.primary.copy(alpha = 0.15f), + ) {} + + Row( + modifier = Modifier + .fillMaxSize() + .selectableGroup(), + ) { + items.forEachIndexed { index, item -> + val isSelected = index == selectedIndex + // KernelSU's non-blur path keeps tab content onSurface and lets the + // translucent primary indicator alone communicate selection. + val contentColor = MiuixTheme.colorScheme.onSurface + Surface( + modifier = Modifier + .width(tabWidth) + .height(56.dp) + .then( + if (isSelected) { + Modifier.draggable( + state = dragState, + orientation = Orientation.Horizontal, + onDragStarted = { + indicatorPosition.stop() + dragPosition = indicatorPosition.value + panelDragOffsetPx = 0f + rubberBandOffsetPx = 0f + isDragging = true + }, + onDragStopped = { + val targetIndex = dragPosition + .roundToInt() + .coerceIn(items.indices) + indicatorPosition.snapTo(dragPosition) + isDragging = false + panelDragOffsetPx = 0f + rubberBandOffsetPx = 0f + onClick(targetIndex) + indicatorPosition.animateTo( + targetValue = targetIndex.toFloat(), + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + visibilityThreshold = 0.001f, + ), + ) + }, + ) + } else { + Modifier + }, + ) + .selectable( + selected = isSelected, + onClick = { onClick(index) }, + role = Role.Tab, + interactionSource = itemInteractionSources[index], + indication = null, + ) + .semantics(mergeDescendants = true) { + role = Role.Tab + this.selected = isSelected + }, + shape = SmoothRoundedCornerShape(28.dp), + color = Color.Transparent, + ) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy( + 1.dp, + Alignment.CenterVertically, + ), + ) { + Icon( + imageVector = item.icon, + contentDescription = null, + tint = contentColor, + ) + Text( + text = item.label, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp), + color = contentColor, + fontSize = 11.sp, + lineHeight = 14.sp, + textAlign = TextAlign.Center, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } + } + } + } else { + NavigationBar( + modifier = modifier, + items = items, + selected = selected, + onClick = onClick, + defaultWindowInsetsPadding = true, + ) + } +} diff --git a/push/src/main/java/top/trumeet/mipushframework/component/RefreshableLazyColumn.kt b/push/src/main/java/top/trumeet/mipushframework/component/RefreshableLazyColumn.kt index a312b6932..6df004e54 100644 --- a/push/src/main/java/top/trumeet/mipushframework/component/RefreshableLazyColumn.kt +++ b/push/src/main/java/top/trumeet/mipushframework/component/RefreshableLazyColumn.kt @@ -1,21 +1,41 @@ package top.trumeet.mipushframework.component +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import com.google.accompanist.swiperefresh.SwipeRefresh import com.google.accompanist.swiperefresh.rememberSwipeRefreshState +import com.xiaomi.xmsf.R +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.launch +import top.yukonga.miuix.kmp.basic.Icon +import top.yukonga.miuix.kmp.basic.Surface +import top.yukonga.miuix.kmp.theme.MiuixTheme +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material.icons.filled.Refresh @Composable fun RefreshableLazyColumn( @@ -23,41 +43,138 @@ fun RefreshableLazyColumn( isNeedMore: (lastVisibleIndex: Int) -> Boolean, doLoadMore: (onRefreshed: () -> Unit) -> Unit, isNeedRefresh: Boolean = false, + modifier: Modifier = Modifier, + bottomContentPadding: PaddingValues = PaddingValues(bottom = 112.dp), content: LazyListScope.() -> Unit ) { val currentIsNeedMore by rememberUpdatedState(isNeedMore) val currentDoLoadMore by rememberUpdatedState(doLoadMore) + val currentDoRefresh by rememberUpdatedState(doRefresh) - var isRefreshing by remember { mutableStateOf(false) } - val onRefreshed by remember { mutableStateOf({ isRefreshing = false }) } + var isLoading by remember { mutableStateOf(false) } + val currentIsLoading by rememberUpdatedState(isLoading) + val requestLock = remember { Mutex() } + val lazyListState = rememberLazyListState() - if (isNeedRefresh) { - isRefreshing = true - SideEffect { - doRefresh(onRefreshed) + // Both pull-to-refresh and the end-of-list observer enter through this gate. A Mutex is + // intentionally held until the caller invokes onRefreshed, so a slow database/network load + // cannot start a second request while the first one is still in flight. + fun request(work: ((() -> Unit) -> Unit)) { + if (!requestLock.tryLock()) return + isLoading = true + var completed = false + val complete = { + if (!completed) { + completed = true + isLoading = false + requestLock.unlock() + } } + try { + work(complete) + } catch (t: Throwable) { + complete() + throw t + } + } + + // Initial loads are launched from an effect keyed by the flag. This avoids SideEffect + // repeatedly dispatching refreshes on every recomposition (which was the source of the + // occasional refresh storm). + LaunchedEffect(isNeedRefresh) { + if (isNeedRefresh) request(currentDoRefresh) } SwipeRefresh( - state = rememberSwipeRefreshState(isRefreshing), + state = rememberSwipeRefreshState(isLoading), onRefresh = { - isRefreshing = true - doRefresh(onRefreshed) + request(currentDoRefresh) } ) { - val lazyListState = rememberLazyListState() LaunchedEffect(lazyListState) { - snapshotFlow { lazyListState.layoutInfo.visibleItemsInfo } - .collect { visibleItems -> - if (isRefreshing) return@collect - val lastIndex = if (visibleItems.isNotEmpty()) - visibleItems.last().index else 0 - if (currentIsNeedMore(lastIndex)) { - isRefreshing = true - currentDoLoadMore(onRefreshed) - } + snapshotFlow { + lazyListState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 + }.collect { lastIndex -> + if (!currentIsLoading && currentIsNeedMore(lastIndex)) { + request(currentDoLoadMore) } + } + } + Box(modifier = modifier.fillMaxSize()) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = lazyListState, + contentPadding = bottomContentPadding, + content = content, + ) + RefreshFloatingActions( + modifier = Modifier.align(Alignment.BottomEnd), + listState = lazyListState, + loading = isLoading, + onRefresh = { request(currentDoRefresh) }, + ) + } + } +} + +@Composable +private fun RefreshFloatingActions( + modifier: Modifier = Modifier, + listState: androidx.compose.foundation.lazy.LazyListState, + loading: Boolean, + onRefresh: () -> Unit, +) { + val scope = rememberCoroutineScope() + val canGoTop = listState.firstVisibleItemIndex > 0 || + listState.firstVisibleItemScrollOffset > 0 + Column( + modifier = modifier + .padding(end = 16.dp, bottom = 80.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalAlignment = Alignment.End, + ) { + MiuixCircularAction( + icon = Icons.Default.KeyboardArrowUp, + contentDescription = stringResource(R.string.action_back_to_top), + enabled = canGoTop, + onClick = { + // The button only becomes enabled after scrolling, so this animation is cheap and + // does not allocate a second list state. + scope.launch { listState.animateScrollToItem(0) } + }, + ) + MiuixCircularAction( + icon = Icons.Default.Refresh, + contentDescription = stringResource(R.string.action_refresh), + enabled = !loading, + onClick = onRefresh, + ) + } +} + +@Composable +private fun MiuixCircularAction( + icon: ImageVector, + contentDescription: String, + enabled: Boolean, + onClick: () -> Unit, +) { + Surface( + modifier = Modifier.size(48.dp), + onClick = onClick, + enabled = enabled, + shape = CircleShape, + color = if (enabled) MiuixTheme.colorScheme.primary + else MiuixTheme.colorScheme.surfaceContainerHigh, + shadowElevation = 3f, + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = icon, + contentDescription = contentDescription, + tint = if (enabled) MiuixTheme.colorScheme.onPrimary + else MiuixTheme.colorScheme.onSurfaceVariantSummary, + ) } - LazyColumn(Modifier.fillMaxSize(), state = lazyListState, content = content) } -} \ No newline at end of file +} diff --git a/push/src/main/java/top/trumeet/mipushframework/component/SearchBar.kt b/push/src/main/java/top/trumeet/mipushframework/component/SearchBar.kt index 2954bbbef..6028ae58a 100644 --- a/push/src/main/java/top/trumeet/mipushframework/component/SearchBar.kt +++ b/push/src/main/java/top/trumeet/mipushframework/component/SearchBar.kt @@ -1,73 +1,131 @@ package top.trumeet.mipushframework.component -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Clear -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.Text -import androidx.compose.material3.TextField -import androidx.compose.material3.TextFieldDefaults -import androidx.compose.material3.TopAppBar +import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import top.yukonga.miuix.kmp.basic.Icon +import top.yukonga.miuix.kmp.basic.IconButton +import top.yukonga.miuix.kmp.basic.InputField +import top.yukonga.miuix.kmp.basic.SearchBar as MiuixSearchBar +import top.yukonga.miuix.kmp.basic.Text +import top.yukonga.miuix.kmp.icon.MiuixIcons +import top.yukonga.miuix.kmp.icon.icons.Search +import top.yukonga.miuix.kmp.theme.MiuixTheme @Composable -@OptIn(ExperimentalMaterial3Api::class) fun SearchBar(placeholder: String, onValueChange: (String) -> Unit) { - val focusManager = LocalFocusManager.current var query by rememberSaveable { mutableStateOf("") } - val ph by rememberUpdatedState(placeholder) - val debounceOnValueChange: (String) -> Unit = debounce(onValueChange) - val change: (String) -> Unit = { query = it; debounceOnValueChange(it) } - TopAppBar(title = { - TextField( - value = query, - onValueChange = change, - modifier = Modifier.fillMaxWidth(), - placeholder = { Text(ph) }, - trailingIcon = { - if (query.isNotEmpty()) { - IconButton({ change("") }) { - Icon(Icons.Default.Clear, contentDescription = "Clear") + var expanded by rememberSaveable { mutableStateOf(false) } + val currentPlaceholder by rememberUpdatedState(placeholder) + val debounceOnValueChange = rememberDebouncedValueChange(onValueChange) + val change: (String) -> Unit = { value -> + query = value + debounceOnValueChange(value) + } + + MiuixSearchBar( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + inputField = { + InputField( + query = query, + onQueryChange = change, + label = currentPlaceholder, + onSearch = { + debounceOnValueChange.flush(it) + expanded = false + }, + expanded = expanded, + onExpandedChange = { expanded = it }, + leadingIcon = { + IconButton( + onClick = { expanded = true }, + backgroundColor = Color.Transparent, + ) { + Icon( + imageVector = MiuixIcons.Search, + contentDescription = null, + tint = MiuixTheme.colorScheme.onSurfaceContainerHigh, + ) } - } - }, - singleLine = true, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - colors = TextFieldDefaults.textFieldColors(containerColor = Color.Transparent) - ) - }) + }, + trailingIcon = if (query.isNotEmpty()) { + { + IconButton( + onClick = { change("") }, + modifier = Modifier.semantics { + contentDescription = "Clear search" + }, + backgroundColor = Color.Transparent, + ) { + Text( + text = "×", + color = MiuixTheme.colorScheme.onSurfaceVariantActions, + fontSize = MiuixTheme.textStyles.title4.fontSize, + ) + } + } + } else { + null + }, + ) + }, + expanded = expanded, + onExpandedChange = { expanded = it }, + content = {}, + ) +} + +private class DebouncedValueChange( + private val onValueChange: (String) -> Unit, + private val launch: (suspend () -> Unit) -> Job, +) { + private var job: Job? = null + + operator fun invoke(value: String) { + job?.cancel() + job = launch { + delay(300) + onValueChange(value) + } + } + + fun flush(value: String) { + job?.cancel() + onValueChange(value) + } + + fun cancel() { + job?.cancel() + } } @Composable -private fun debounce(onValueChange: (String) -> Unit): (String) -> Unit { +private fun rememberDebouncedValueChange(onValueChange: (String) -> Unit): DebouncedValueChange { + val currentOnValueChange by rememberUpdatedState(onValueChange) val scope = rememberCoroutineScope() - var job: Job? = null - val change: (String) -> Unit = { - scope.launch { - job?.cancel() - job = launch { - delay(300) - onValueChange(it) - } - } + val debounced = remember(scope) { + DebouncedValueChange( + onValueChange = { currentOnValueChange(it) }, + launch = { block -> scope.launch { block() } }, + ) } - return change -} \ No newline at end of file + DisposableEffect(debounced) { + onDispose(debounced::cancel) + } + return debounced +} diff --git a/push/src/main/java/top/trumeet/mipushframework/component/SettingsComponent.kt b/push/src/main/java/top/trumeet/mipushframework/component/SettingsComponent.kt index 8a0c84e03..456ce803c 100644 --- a/push/src/main/java/top/trumeet/mipushframework/component/SettingsComponent.kt +++ b/push/src/main/java/top/trumeet/mipushframework/component/SettingsComponent.kt @@ -1,6 +1,7 @@ package top.trumeet.mipushframework.component import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope @@ -11,27 +12,32 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.RadioButton -import androidx.compose.material3.Switch -import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.scale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringArrayResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import com.xiaomi.xmsf.R import com.xiaomi.xmsf.utils.ConfigCenter +import top.yukonga.miuix.kmp.basic.BasicComponent +import top.yukonga.miuix.kmp.basic.Card +import top.yukonga.miuix.kmp.basic.Checkbox +import top.yukonga.miuix.kmp.basic.SmallTitle +import top.yukonga.miuix.kmp.basic.Switch +import top.yukonga.miuix.kmp.basic.Text +import top.yukonga.miuix.kmp.extra.SuperDialog +import top.yukonga.miuix.kmp.theme.MiuixTheme +import top.yukonga.miuix.kmp.utils.MiuixPopupUtil.Companion.dismissDialog @Composable fun SettingsItem( @@ -41,18 +47,15 @@ fun SettingsItem( enabled: Boolean = true, onClick: () -> Unit ) { - Row( - Modifier - .clickable(onClick = onClick, enabled = enabled) - .fillMaxWidth() - .padding(5.dp) - .heightIn(min = 40.dp) - .alpha(if (enabled) 1f else 0.5f), - verticalAlignment = Alignment.CenterVertically - ) { - ItemInfo(title, summary, modifier = Modifier.weight(9f)) - content?.let { it() } - } + BasicComponent( + modifier = Modifier.fillMaxWidth(), + insideMargin = DpSize(16.dp, 14.dp), + title = title, + summary = summary, + rightActions = { content?.invoke(this) }, + enabled = enabled, + onClick = onClick, + ) } @Composable @@ -63,16 +66,11 @@ fun SettingsItem( values: Array, defaultValue: String ) { - var shouldShowDialog by remember { mutableStateOf(false) } SettingsItem( title = title, summary = summary, confirmButton = {}, - content = { - ItemLists(key, defaultValue, values) { - shouldShowDialog = false - } - } + content = { dismiss -> ItemLists(key, defaultValue, values, dismiss) }, ) } @@ -85,24 +83,26 @@ fun SettingsItem( content: @Composable (dismiss: () -> Unit) -> Unit ) { var shouldShowDialog by remember { mutableStateOf(false) } + val hideDialog = { + shouldShowDialog = false + onDismiss?.invoke() + Unit + } + SettingsItem( title = title, summary = summary, content = { - val hideDialog = { - shouldShowDialog = false - onDismiss?.invoke() - Unit - } - SettingsDialog(title, shouldShowDialog, hideDialog, { - confirmButton(hideDialog) - }) { - content(hideDialog) - } - } - ) { - shouldShowDialog = true - } + SettingsDialog( + title = title, + shouldShowDialog = shouldShowDialog, + onDismiss = hideDialog, + confirmButton = { confirmButton(hideDialog) }, + content = { content(hideDialog) }, + ) + }, + onClick = { shouldShowDialog = true }, + ) } @Composable @@ -113,13 +113,36 @@ fun SettingsDialog( confirmButton: @Composable () -> Unit, content: @Composable () -> Unit ) { - if (!shouldShowDialog) return - AlertDialog( - onDismissRequest = onDismiss, - confirmButton = confirmButton, - title = { Text(title) }, - text = content - ) + val show = remember { mutableStateOf(false) } + val dismiss by rememberUpdatedState(onDismiss) + + LaunchedEffect(shouldShowDialog) { + if (shouldShowDialog) { + show.value = true + } else { + dismissDialog(show) + } + } + + SuperDialog( + title = title, + show = show, + onDismissRequest = { + dismissDialog(show) + dismiss() + }, + ) { + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + content() + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + confirmButton() + } + } + } } @Composable @@ -131,26 +154,26 @@ private fun ItemLists( ) { val context = LocalContext.current val preferences = ConfigCenter.getSharedPreferences(context) - val selected = preferences.getString(key, defaultValue)!!.toInt() + val selected = preferences.getString(key, defaultValue)?.toIntOrNull() ?: 0 - LazyColumn { + LazyColumn(modifier = Modifier.heightIn(max = 420.dp)) { itemsIndexed(values) { index, item -> Row( - Modifier + modifier = Modifier .clickable { - preferences - .edit() - .putString(key, index.toString()) - .apply() + preferences.edit().putString(key, index.toString()).apply() onDismiss() } .fillMaxWidth() - .padding(top = 10.dp, bottom = 10.dp), - verticalAlignment = Alignment.CenterVertically + .padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, ) { - RadioButton(index == selected, onClick = null) - Spacer(modifier = Modifier.width(5.dp)) - Text(text = item) + Checkbox( + checked = index == selected, + onCheckedChange = null, + ) + Spacer(modifier = Modifier.width(12.dp)) + Text(text = item, style = MiuixTheme.textStyles.body1) } } } @@ -159,12 +182,17 @@ private fun ItemLists( @Composable fun SettingsGroup(title: String, content: @Composable () -> Unit) { Column( - Modifier + modifier = Modifier .fillMaxWidth() - .padding(10.dp) + .padding(horizontal = 12.dp, vertical = 6.dp), ) { - Text(title, style = MaterialTheme.typography.labelLarge) - content() + SmallTitle( + text = title, + insideMargin = DpSize(16.dp, 8.dp), + ) + Card(modifier = Modifier.fillMaxWidth()) { + content() + } } } @@ -179,10 +207,15 @@ fun SettingsItem( ) { val context = LocalContext.current val preferences = ConfigCenter.getSharedPreferences(context) - var checked by remember { mutableStateOf(preferences.getBoolean(key, defaultValue)) } - SettingsItem(title = title, summary = summary, checked = checked, enabled = enabled) { - preferences.edit().putBoolean(key, !checked).apply() + var checked by remember(key) { mutableStateOf(preferences.getBoolean(key, defaultValue)) } + SettingsItem( + title = title, + summary = summary, + checked = checked, + enabled = enabled, + ) { checked = !checked + preferences.edit().putBoolean(key, checked).apply() onClick?.invoke(checked) } } @@ -196,23 +229,34 @@ fun SettingsItem( onClick: () -> Unit ) { SettingsItem( - title, summary, content = { + title = title, + summary = summary, + content = { Switch( checked = checked, onCheckedChange = null, - modifier = Modifier.scale(0.7f) + enabled = enabled, ) - }, enabled = enabled, - onClick = onClick + }, + enabled = enabled, + onClick = onClick, ) } @Composable fun ItemInfo(title: String, summary: String?, modifier: Modifier = Modifier) { - Column(modifier.padding(start = 10.dp, end = 10.dp)) { - Text(title, style = MaterialTheme.typography.bodyLarge) + Column(modifier.padding(horizontal = 10.dp)) { + Text( + text = title, + style = MiuixTheme.textStyles.headline1, + color = MiuixTheme.colorScheme.onSurface, + ) summary?.let { - Text(it, style = MaterialTheme.typography.bodyMedium) + Text( + text = it, + style = MiuixTheme.textStyles.body2, + color = MiuixTheme.colorScheme.onSurfaceVariantSummary, + ) } } } @@ -223,13 +267,15 @@ fun InfoDialogPreview() { SettingsDialog( title = stringResource(R.string.pref_title_access_mode), shouldShowDialog = true, - {}, {} + onDismiss = {}, + confirmButton = {}, ) { ItemLists( - "AccessMode", - "0", - stringArrayResource(R.array.pref_title_access_mode_list_titles) - ) { } + key = "AccessMode", + defaultValue = "0", + values = stringArrayResource(R.array.pref_title_access_mode_list_titles), + onDismiss = {}, + ) } } @@ -241,14 +287,12 @@ fun SettingsItemPreview() { summary = stringResource(R.string.settings_start_foreground_service_summary), key = "StartForegroundService", defaultValue = false, - enabled = false + enabled = false, ) } @Preview(showBackground = true) @Composable fun SingleLineSettingsItemPreview() { - SettingsItem( - title = stringResource(R.string.settings_start_foreground_service) - ) {} -} \ No newline at end of file + SettingsItem(title = stringResource(R.string.settings_start_foreground_service)) {} +} diff --git a/push/src/main/java/top/trumeet/mipushframework/main/AdvancedSettingsPage.kt b/push/src/main/java/top/trumeet/mipushframework/main/AdvancedSettingsPage.kt index 8319ba8ba..aabcafa97 100644 --- a/push/src/main/java/top/trumeet/mipushframework/main/AdvancedSettingsPage.kt +++ b/push/src/main/java/top/trumeet/mipushframework/main/AdvancedSettingsPage.kt @@ -1,19 +1,21 @@ package top.trumeet.mipushframework.main +import android.app.AlarmManager import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build import android.os.Bundle +import android.provider.Settings import androidx.activity.ComponentActivity import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.NavigationBarDefaults -import androidx.compose.material3.Surface -import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -30,40 +32,40 @@ import com.xiaomi.xmsf.R import com.xiaomi.xmsf.SettingUtils import com.xiaomi.xmsf.utils.ConfigCenter import top.trumeet.common.utils.Utils +import top.trumeet.mipushframework.component.MiuixPageScaffold import top.trumeet.mipushframework.component.SettingsGroup import top.trumeet.mipushframework.component.SettingsItem import top.trumeet.ui.theme.Theme - +import top.yukonga.miuix.kmp.basic.Surface +import top.yukonga.miuix.kmp.theme.MiuixTheme class AdvancedSettingsPage : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { Theme { - window.navigationBarColor = MaterialTheme.colorScheme.surfaceColorAtElevation( - NavigationBarDefaults.Elevation - ).toArgb() + window.navigationBarColor = MiuixTheme.colorScheme.surfaceContainer.toArgb() + SettingsApp() } - SettingsApp() } } } @Composable private fun SettingsApp() { - Theme { + MiuixPageScaffold(modifier = Modifier.fillMaxSize()) { paddingValues -> Surface( modifier = Modifier .fillMaxSize() + .padding(paddingValues) .verticalScroll(rememberScrollState()), - color = MaterialTheme.colorScheme.background + color = MiuixTheme.colorScheme.background ) { SettingsScreen() } } } - @Composable private fun SettingsScreen() { Column { @@ -113,12 +115,53 @@ fun ConfigurationsBlock() { values = stringArrayResource(R.array.pref_title_access_mode_list_titles), defaultValue = "0" ) + val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as? AlarmManager + val exactAllowed = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + alarmManager?.canScheduleExactAlarms() == true + } else { + true + } + SettingsItem( + title = stringResource(R.string.settings_alarm_schedule_policy), + summary = if (exactAllowed) { + stringResource(R.string.settings_alarm_schedule_exact) + } else { + stringResource(R.string.settings_alarm_schedule_inexact) + } + ) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + try { + val intent = Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM).apply { + data = Uri.parse("package:${context.packageName}") + } + context.startActivity(intent) + } catch (e: Exception) { + try { + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.parse("package:${context.packageName}") + } + context.startActivity(intent) + } catch (ignored: Exception) {} + } + } + } } } @Composable private fun ExperimentalBlock() { val context = LocalContext.current + val focusProtocolVersion = remember { + try { + Settings.System.getInt( + context.contentResolver, + "notification_focus_protocol", + 0, + ) + } catch (_: Throwable) { + 0 + } + } var iceBoxGranted by remember { mutableStateOf( SettingUtils.isIceBoxInstalled() @@ -133,12 +176,29 @@ private fun ExperimentalBlock() { } SettingsGroup(title = stringResource(R.string.settings_experimental)) { + SettingsItem( + title = stringResource(R.string.settings_focus_protocol_status), + summary = if (focusProtocolVersion > 0) { + stringResource( + R.string.settings_focus_protocol_available, + focusProtocolVersion, + ) + } else { + stringResource(R.string.settings_focus_protocol_unavailable) + }, + ) {} SettingsItem( title = stringResource(R.string.settings_mock_notification), summary = stringResource(R.string.settings_mock_notification_summary) ) { SettingUtils.notifyMockNotification(context) } + SettingsItem( + title = stringResource(R.string.settings_mock_focus_notification), + summary = stringResource(R.string.settings_mock_focus_notification_summary) + ) { + SettingUtils.notifyMockFocusNotification(context) + } SettingsItem( title = stringResource(R.string.settings_icebox_permission), @@ -186,4 +246,3 @@ private fun SettingsPreview() { Utils.context = LocalContext.current SettingsApp() } - diff --git a/push/src/main/java/top/trumeet/mipushframework/main/AppConfigurationUtils.java b/push/src/main/java/top/trumeet/mipushframework/main/AppConfigurationUtils.java index e3438bd1e..fba5b7461 100644 --- a/push/src/main/java/top/trumeet/mipushframework/main/AppConfigurationUtils.java +++ b/push/src/main/java/top/trumeet/mipushframework/main/AppConfigurationUtils.java @@ -16,7 +16,6 @@ import com.xiaomi.xmsf.R; import com.xiaomi.xmsf.push.notification.NotificationChannelManager; -import java.util.Arrays; import java.util.List; import top.trumeet.common.Constants; @@ -34,26 +33,16 @@ public AppConfigurationUtils(Context context, RegisteredApplication application) } boolean shouldSuggestFakeApp(String pkg) { - return !isBlacklistApp(pkg) && Utils.isUserApplication(pkg); - } - - boolean isBlacklistApp(String pkg) { - return isBlacklistContaines(pkg) || isBlacklistMatches(pkg); - } - - boolean isBlacklistMatches(String pkg) { - String[] pkgsContains = context.getResources().getStringArray(R.array.fake_blacklist_contains); - for (String p : pkgsContains) - if (pkg.contains(p)) - return true; - return false; + if (TextUtils.isEmpty(pkg)) { + return false; + } + return isFakeSuggestionEligible( + Utils.isUserApplication(pkg), TextUtils.equals(pkg, context.getPackageName())); } - boolean isBlacklistContaines(String pkg) { - List pkgsEqual = Arrays.asList(context.getResources().getStringArray(R.array.fake_blacklist_equals)); - if (pkgsEqual.contains(pkg)) - return true; - return false; + /** Package text never affects this policy: only install type and self identity do. */ + static boolean isFakeSuggestionEligible(boolean userApplication, boolean ownApplication) { + return userApplication && !ownApplication; } void gotoRecentEventsPage() { @@ -64,7 +53,7 @@ void gotoRecentEventsPage() { void gotoNotificationSettingPage() { context.startActivity(new Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS) - .putExtra(Settings.EXTRA_APP_PACKAGE, Constants.SERVICE_APP_NAME)); + .putExtra(Settings.EXTRA_APP_PACKAGE, context.getPackageName())); } static void removeAllNonMIPushGroup(List groups, String mipushGroup) { @@ -141,4 +130,4 @@ String getConfigApp() { } return groups; } -} \ No newline at end of file +} diff --git a/push/src/main/java/top/trumeet/mipushframework/main/ApplicationIconCache.kt b/push/src/main/java/top/trumeet/mipushframework/main/ApplicationIconCache.kt index 31b8f8fa3..3d841f443 100644 --- a/push/src/main/java/top/trumeet/mipushframework/main/ApplicationIconCache.kt +++ b/push/src/main/java/top/trumeet/mipushframework/main/ApplicationIconCache.kt @@ -8,7 +8,7 @@ import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.res.imageResource import androidx.core.graphics.drawable.toBitmap -import java.util.concurrent.ConcurrentHashMap +import androidx.collection.LruCache class ApplicationIconCache(context: Context) { val context: Context = context.applicationContext @@ -20,18 +20,35 @@ class ApplicationIconCache(context: Context) { ) ) } - private val iconCache = ConcurrentHashMap() + // The settings UI can enumerate hundreds of packages. Keep only a bounded + // working set so visiting the app list cannot retain every decoded icon. + private val iconCache = LruCache(48) fun get(packageName: String): Painter? { - return iconCache[packageName] + return iconCache.get(packageName) } fun cache(packageName: String): Painter { val icon = getAppIcon(packageName) - iconCache[packageName] = icon + iconCache.put(packageName, icon) return icon } + fun trimMemory(level: Int) { + when { + level >= android.content.ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL -> + iconCache.evictAll() + level >= android.content.ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW -> + iconCache.trimToSize(24) + level >= android.content.ComponentCallbacks2.TRIM_MEMORY_RUNNING_MODERATE -> + iconCache.trimToSize(12) + } + } + + fun clearMemory() { + iconCache.evictAll() + } + private fun getAppIcon(packageName: String): BitmapPainter { try { val applicationIcon = context.packageManager.getApplicationIcon(packageName) @@ -41,4 +58,4 @@ class ApplicationIconCache(context: Context) { } } -} \ No newline at end of file +} diff --git a/push/src/main/java/top/trumeet/mipushframework/main/ApplicationInfoPage.kt b/push/src/main/java/top/trumeet/mipushframework/main/ApplicationInfoPage.kt index 8a8ae0cbf..78a5fbd9f 100644 --- a/push/src/main/java/top/trumeet/mipushframework/main/ApplicationInfoPage.kt +++ b/push/src/main/java/top/trumeet/mipushframework/main/ApplicationInfoPage.kt @@ -18,18 +18,13 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.NavigationBarDefaults -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -37,13 +32,16 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.graphics.drawable.toBitmap @@ -57,7 +55,28 @@ import top.trumeet.mipush.provider.db.RegisteredApplicationDb import top.trumeet.mipush.provider.entities.RegisteredApplication import top.trumeet.mipush.provider.entities.RegisteredApplication.RegisteredType import top.trumeet.mipushframework.component.MarkdownView +import top.trumeet.mipushframework.component.MiuixActionButton +import top.trumeet.mipushframework.component.MiuixActionIconButton +import top.trumeet.mipushframework.component.MiuixPageScaffold import top.trumeet.ui.theme.Theme +import top.yukonga.miuix.kmp.basic.Button +import top.yukonga.miuix.kmp.basic.Card +import top.yukonga.miuix.kmp.basic.Icon +import top.yukonga.miuix.kmp.basic.Surface +import top.yukonga.miuix.kmp.basic.Text +import top.yukonga.miuix.kmp.theme.MiuixTheme + +internal enum class RegistrationAction { + REGISTER, + REREGISTER, +} + +internal fun registrationActionFor(registeredType: Int): RegistrationAction? = + when (registeredType) { + RegisteredType.NotRegistered -> RegistrationAction.REGISTER + RegisteredType.Unregistered -> RegistrationAction.REREGISTER + else -> null + } class ApplicationInfoPage : ComponentActivity() { companion object { @@ -74,14 +93,20 @@ class ApplicationInfoPage : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - init(getRegisteredApplication()!!) + val registeredApplication = getRegisteredApplication() + if (registeredApplication == null) { + // A stale event may refer to a package that has already been unregistered or + // removed. Finishing this entry point is safer than dereferencing a null record and + // gives callers a predictable no-crash result. + finish() + return + } + init(registeredApplication) setContent { Theme { - window.navigationBarColor = MaterialTheme.colorScheme.surfaceColorAtElevation( - NavigationBarDefaults.Elevation - ).toArgb() + window.navigationBarColor = MiuixTheme.colorScheme.surfaceContainer.toArgb() + SettingsApp() } - SettingsApp() } } @@ -113,12 +138,13 @@ class ApplicationInfoPage : ComponentActivity() { ) } - Theme { + MiuixPageScaffold(modifier = Modifier.fillMaxSize()) { paddingValues -> Surface( modifier = Modifier .fillMaxSize() + .padding(paddingValues) .verticalScroll(rememberScrollState()), - color = MaterialTheme.colorScheme.background + color = MiuixTheme.colorScheme.background ) { SettingsScreen() } @@ -139,44 +165,64 @@ class ApplicationInfoPage : ComponentActivity() { fun ApplicationInfoHeader() { val context = LocalContext.current val isPreview = LocalInspectionMode.current - val drawable = if (isPreview) - AppCompatResources.getDrawable(context, android.R.mipmap.sym_def_app_icon)!! - else applicationInfo.getIcon(context) - val icon = drawable.toBitmap().asImageBitmap() - Row( - modifier = Modifier.padding(10.dp), - verticalAlignment = Alignment.CenterVertically + val density = LocalDensity.current + val icon = remember(applicationInfo.packageName, density) { + val drawable = if (isPreview) + AppCompatResources.getDrawable(context, android.R.mipmap.sym_def_app_icon)!! + else applicationInfo.getIcon(context) + val iconSizePx = with(density) { 40.dp.roundToPx() } + drawable.toBitmap(iconSizePx, iconSizePx).asImageBitmap() + } + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp), ) { - IconButton({ - RegistrationHelper( - context, - applicationInfo.packageName - ).deleteRegistrationInfoAndRetryForceRegister() - }) { - Image(icon, "Application Icon") - } - Column(Modifier.weight(1f)) { - Text( - applicationInfo.appName, - style = MaterialTheme.typography.bodyMedium - ) - Text( - applicationInfo.packageName, - style = MaterialTheme.typography.bodySmall - ) - } - IconButton({ - val uri = Uri.fromParts("package", applicationInfo.packageName, null) - context.startActivity( - Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) - .setData(uri) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - ) - }) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { Image( - painterResource(R.drawable.ic_info), - stringResource(R.string.application_info_label) + bitmap = icon, + contentDescription = null, + modifier = Modifier.size(40.dp), + contentScale = ContentScale.Fit, ) + Spacer(Modifier.width(16.dp)) + Column(Modifier.weight(1f)) { + Text( + text = applicationInfo.appName, + style = MiuixTheme.textStyles.body1, + color = MiuixTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = applicationInfo.packageName, + style = MiuixTheme.textStyles.footnote1, + color = MiuixTheme.colorScheme.onSurfaceVariantSummary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Spacer(Modifier.width(12.dp)) + MiuixActionIconButton(onClick = { + val uri = Uri.fromParts("package", applicationInfo.packageName, null) + context.startActivity( + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + .setData(uri) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ) + }) { + Image( + painterResource(R.drawable.ic_info), + stringResource(R.string.application_info_label), + modifier = Modifier.size(24.dp), + contentScale = ContentScale.Fit, + ) + } } } } @@ -192,20 +238,39 @@ class ApplicationInfoPage : ComponentActivity() { private fun Tips() { val shouldSuggestFakeApp: Boolean = appConfigurationUtils.shouldSuggestFakeApp(applicationInfo.packageName) + val context = LocalContext.current + val onRegistrationAction = { + RegistrationHelper( + context, + applicationInfo.packageName + ).deleteRegistrationInfoAndRetryForceRegister() + } - val registeredType: Int = applicationInfo.registeredType - if (registeredType == RegisteredType.NotRegistered) { - val notRegisteredDesc = stringResource( - if (shouldSuggestFakeApp) - R.string.status_app_not_registered_detail_with_fake_suggest - else R.string.status_app_not_registered_detail_without_fake_suggest - ) - Tips(stringResource(R.string.status_app_not_registered_title), notRegisteredDesc) - } else if (registeredType == RegisteredType.Unregistered) { - Tips( - stringResource(R.string.status_app_registered_error_title), - stringResource(R.string.status_app_registered_error_desc) + when (registrationActionFor(applicationInfo.registeredType)) { + RegistrationAction.REGISTER -> { + val notRegisteredDesc = stringResource( + if (shouldSuggestFakeApp) + R.string.status_app_not_registered_detail_with_fake_suggest + else R.string.status_app_not_registered_detail_without_fake_suggest + ) + Tips( + title = stringResource(R.string.status_app_not_registered_title), + description = notRegisteredDesc, + actionDescription = stringResource(R.string.registration_action_description), + actionLabel = stringResource(R.string.registration_action_register_now), + onAction = onRegistrationAction, + ) + } + + RegistrationAction.REREGISTER -> Tips( + title = stringResource(R.string.status_app_registered_error_title), + description = stringResource(R.string.status_app_registered_error_desc), + actionDescription = stringResource(R.string.registration_action_description), + actionLabel = stringResource(R.string.registration_action_register_again), + onAction = onRegistrationAction, ) + + null -> Unit } } @@ -266,16 +331,22 @@ class ApplicationInfoPage : ComponentActivity() { private fun NotificationCategory(categoryName: String, channels: List) { SettingsGroup(categoryName) { channels.forEach { channel -> - SettingsItem( - title = AppConfigurationUtils.getNotificationTitle( - channel - ).toString(), - summary = AppConfigurationUtils.getNotificationSummary( - channel - ), - confirmButton = {}, + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp), ) { - NotificationChannel(channel, appConfigurationUtils) + SettingsItem( + title = AppConfigurationUtils.getNotificationTitle( + channel + ).toString(), + summary = AppConfigurationUtils.getNotificationSummary( + channel + ), + confirmButton = {}, + ) { + NotificationChannel(channel, appConfigurationUtils) + } } } } @@ -283,32 +354,64 @@ class ApplicationInfoPage : ComponentActivity() { @Composable private fun ManageNotificationItem() { - SettingsItem( - title = stringResource(R.string.settings_manage_app_notifications), - summary = stringResource(R.string.settings_manage_app_notifications_summary), - enabled = applicationInfo.registeredType == RegisteredType.NotRegistered, + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 6.dp), ) { - appConfigurationUtils.gotoNotificationSettingPage() + SettingsItem( + title = stringResource(R.string.settings_manage_app_notifications), + summary = stringResource(R.string.settings_manage_app_notifications_summary), + enabled = applicationInfo.registeredType == RegisteredType.NotRegistered, + ) { + appConfigurationUtils.gotoNotificationSettingPage() + } } } } @Composable -fun Tips(title: String, description: String) { - Row(modifier = Modifier.padding(10.dp)) { - Icon( - painterResource(R.drawable.ic_error_outline_black_24dp), null, - tint = Color(0xFFD50000) - ) - Spacer(Modifier.width(10.dp)) - Column { - Text(title, style = MaterialTheme.typography.bodyMedium) - - MarkdownView( - description, - textSize = MaterialTheme.typography.bodySmall.fontSize.value, +fun Tips( + title: String, + description: String, + actionDescription: String? = null, + actionLabel: String? = null, + onAction: (() -> Unit)? = null, +) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 6.dp), + ) { + Row(modifier = Modifier.padding(16.dp)) { + Icon( + painterResource(R.drawable.ic_error_outline_black_24dp), null, + tint = Color(0xFFD50000) ) + Spacer(Modifier.width(10.dp)) + Column { + Text(title, style = MiuixTheme.textStyles.body2) + + MarkdownView( + description, + textSize = MiuixTheme.textStyles.footnote1.fontSize.value, + ) + if (actionDescription != null && actionLabel != null && onAction != null) { + Spacer(Modifier.height(12.dp)) + Text( + text = actionDescription, + style = MiuixTheme.textStyles.footnote1, + color = MiuixTheme.colorScheme.onSurfaceVariantSummary, + ) + Spacer(Modifier.height(10.dp)) + Button( + modifier = Modifier.align(Alignment.End), + text = actionLabel, + onClick = onAction, + ) + } + } } } } @@ -318,17 +421,17 @@ private fun NotificationChannel( channel: NotificationChannel, appConfigurationUtils: AppConfigurationUtils ) { Row { - TextButton({ + MiuixActionButton(onClick = { appConfigurationUtils.deleteNotificationChannel(channel) }) { Text(stringResource(R.string.notification_channels_delete)) } - TextButton({ + MiuixActionButton(onClick = { appConfigurationUtils.copyToClipboard(channel) }) { Text(stringResource(R.string.notification_channels_copy_id)) } - TextButton({ + MiuixActionButton(onClick = { appConfigurationUtils.gotoNotificationChannelSettingPage( channel, appConfigurationUtils.configApp diff --git a/push/src/main/java/top/trumeet/mipushframework/main/HelpPage.kt b/push/src/main/java/top/trumeet/mipushframework/main/HelpPage.kt index a2be0f6b3..8571537ae 100644 --- a/push/src/main/java/top/trumeet/mipushframework/main/HelpPage.kt +++ b/push/src/main/java/top/trumeet/mipushframework/main/HelpPage.kt @@ -1,22 +1,17 @@ package top.trumeet.mipushframework.main - import android.content.Context import android.content.Intent import android.net.Uri import android.os.Bundle import androidx.activity.compose.setContent import androidx.appcompat.app.AppCompatActivity -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Divider -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview @@ -25,19 +20,21 @@ import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController -import com.google.android.material.elevation.SurfaceColors import com.xiaomi.xmsf.R import top.trumeet.mipushframework.component.MarkdownView import top.trumeet.ui.theme.Theme +import top.yukonga.miuix.kmp.basic.BasicComponent +import top.yukonga.miuix.kmp.basic.SmallTitle +import top.yukonga.miuix.kmp.basic.Surface +import top.yukonga.miuix.kmp.theme.MiuixTheme import java.io.InputStreamReader class HelpPage : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { - val color = SurfaceColors.SURFACE_2.getColor(this) - window.statusBarColor = color Theme { + window.statusBarColor = MiuixTheme.colorScheme.surfaceContainer.toArgb() HelpList() } } @@ -67,10 +64,15 @@ fun HelpList(modifier: Modifier = Modifier) { @Composable fun HelpList(navController: NavHostController) { - Column { - FAQ(navController) - Divider() - ContactUs() + Surface(color = MiuixTheme.colorScheme.background) { + Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)) { + Group(stringResource(R.string.helplib_title_faq)) { + FAQ(navController) + } + Group(stringResource(R.string.helplib_title_contact)) { + ContactUs() + } + } } } @@ -85,7 +87,6 @@ private fun Markdown(markdownResId: Int?) { private fun FAQ( navController: NavHostController ) { - Group(stringResource(R.string.helplib_title_faq)) for (article in getArticles(LocalContext.current)) { ClickableListItem(article.titleRes) { navController.navigate("markdown/${article.markdownRes}") // 跳转并传递数据 @@ -96,7 +97,6 @@ private fun FAQ( @Composable private fun ContactUs() { val context = LocalContext.current - Group(stringResource(R.string.helplib_title_contact)) ClickableListItem(R.string.helplib_action_qq_group) { openUrl(context, "https://pd.qq.com/s/4tsiu8hlu") } @@ -109,13 +109,14 @@ private fun ContactUs() { } @Composable -private fun Group(title: String) { - Text( +private fun Group(title: String, content: @Composable () -> Unit) { + SmallTitle( text = title, - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(16.dp) + modifier = Modifier.fillMaxWidth(), ) + top.yukonga.miuix.kmp.basic.Card(modifier = Modifier.fillMaxWidth()) { + content() + } } @Composable @@ -125,19 +126,11 @@ private fun ClickableListItem(textResourceId: Int, onClick: () -> Unit) { @Composable private fun ClickableListItem(item: String, onClick: () -> Unit) { - Row( - modifier = Modifier - .clickable(onClick = onClick) - .padding(16.dp) - .padding(start = 24.dp) - .fillMaxWidth() - ) { - Text( - text = item, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface - ) - } + BasicComponent( + modifier = Modifier.fillMaxWidth(), + title = item, + onClick = onClick, + ) } private fun openUrl(context: Context, url: String) { @@ -166,4 +159,4 @@ private fun readRawFile(context: Context, fileName: Int): String { val inputStream = context.resources.openRawResource(fileName) val reader = InputStreamReader(inputStream) return reader.readText() -} \ No newline at end of file +} diff --git a/push/src/main/java/top/trumeet/mipushframework/main/MainPage.kt b/push/src/main/java/top/trumeet/mipushframework/main/MainPage.kt index 7974ab220..887152719 100644 --- a/push/src/main/java/top/trumeet/mipushframework/main/MainPage.kt +++ b/push/src/main/java/top/trumeet/mipushframework/main/MainPage.kt @@ -1,28 +1,37 @@ package top.trumeet.mipushframework.main +import android.Manifest +import android.os.Build import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent -import androidx.compose.foundation.layout.Arrangement +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.NavigationBar -import androidx.compose.material3.NavigationBarDefaults -import androidx.compose.material3.NavigationBarItem -import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.res.painterResource +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview @@ -34,8 +43,11 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController +import com.nihility.Global import com.xiaomi.xmsf.R import top.trumeet.mipushframework.MainPageUtils +import top.trumeet.mipushframework.component.MiuixBottomNavigation +import top.trumeet.mipushframework.component.MiuixPageScaffold import top.trumeet.mipushframework.component.SearchBar import top.trumeet.mipushframework.main.subpage.ApplicationList import top.trumeet.mipushframework.main.subpage.ApplicationListPreview @@ -44,25 +56,64 @@ import top.trumeet.mipushframework.main.subpage.EventList import top.trumeet.mipushframework.main.subpage.EventListPreview import top.trumeet.mipushframework.main.subpage.Settings import top.trumeet.mipushframework.main.subpage.SettingsPagePreview +import top.trumeet.mipushframework.utils.NotificationPermissionController import top.trumeet.ui.theme.Theme +import top.yukonga.miuix.kmp.basic.NavigationItem +import top.yukonga.miuix.kmp.theme.MiuixTheme -private val mainPageUtils1 = MainPageUtils() private var placeholder by mutableStateOf("Search...") class MainPage : ComponentActivity() { + private val mainPageUtils = MainPageUtils() + private var notificationPermissionAttempted = false + private val notificationPermissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestPermission() + ) { + NotificationPermissionController.markRequested(this) + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) WindowCompat.setDecorFitsSystemWindows(window, false) - mainPageUtils1.initOnCreate(applicationContext) { placeholder = it.toString() } + // Configure the system bar before the first Compose frame. Otherwise Android may keep + // the theme's opaque navigation-bar color for the first layout pass, which is especially + // visible below the floating dock on gesture-navigation devices. + window.navigationBarColor = Color.Transparent.toArgb() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + window.navigationBarDividerColor = Color.Transparent.toArgb() + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + window.isNavigationBarContrastEnforced = false + } + mainPageUtils.initOnCreate(applicationContext) { placeholder = it.toString() } setContent { Theme { - window.navigationBarColor = MaterialTheme.colorScheme.surfaceColorAtElevation( - NavigationBarDefaults.Elevation - ).toArgb() - } - Main(Screen.Apps.route.toString()) { - { + var floatingBottomNav by rememberSaveable { + mutableStateOf(Global.ConfigCenter().isFloatingBottomNavigation(applicationContext)) + } + val navigationBarColor = if (floatingBottomNav) { + Color.Transparent + } else { + MiuixTheme.colorScheme.surfaceContainer + } + val decorBackgroundColor = MiuixTheme.colorScheme.background.toArgb() + SideEffect { + // Some OEM window managers keep drawing the decor background underneath a + // transparent navigation bar. Keep that fallback in sync with the Miuix + // surface so the area outside the floating island never becomes a black row. + window.decorView.setBackgroundColor(decorBackgroundColor) + window.navigationBarColor = navigationBarColor.toArgb() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + window.navigationBarDividerColor = navigationBarColor.toArgb() + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + window.isNavigationBarContrastEnforced = !floatingBottomNav + } + } + Main( + startDestination = Screen.Apps.route.toString(), + floatingBottomNav = floatingBottomNav, + ) { composable(Screen.Events.route.toString()) { Column { var query by rememberSaveable { mutableStateOf("") } @@ -77,70 +128,259 @@ class MainPage : ComponentActivity() { ApplicationList(query) } } - composable(Screen.Settings.route.toString()) { Settings() } + composable(Screen.Settings.route.toString()) { + Settings( + floatingBottomNav = floatingBottomNav, + onFloatingBottomNavChange = { enabled -> + floatingBottomNav = enabled + Global.ConfigCenter().setFloatingBottomNavigation(applicationContext, enabled) + } + ) + } } } } } + + override fun onPostResume() { + super.onPostResume() + if (!notificationPermissionAttempted && + NotificationPermissionController.shouldAutoRequest(this) + ) { + notificationPermissionAttempted = true + NotificationPermissionController.markRequested(this) + notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } + } + + override fun onDestroy() { + super.onDestroy() + mainPageUtils.close() + } } -private sealed class Screen(val route: Int, val icon: Int) { - object Events : Screen(R.string.main_event, R.drawable.ic_event_note_black_24dp) - object Apps : Screen(R.string.main_apps, R.drawable.ic_apps_black_24dp) - object Settings : Screen(R.string.main_settings, R.drawable.ic_settings_black_24dp) +private sealed class Screen(val route: Int, val icon: ImageVector) { + object Events : Screen(R.string.main_event, eventIcon) + object Apps : Screen(R.string.main_apps, appsIcon) + object Settings : Screen(R.string.main_settings, settingsIcon) } +private val eventIcon = ImageVector.Builder( + name = "Events", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, +).apply { + path(fill = SolidColor(Color.Black)) { + moveTo(17f, 10f); lineTo(7f, 10f); verticalLineTo(12f); horizontalLineTo(17f); close() + moveTo(19f, 3f); horizontalLineTo(18f); verticalLineTo(1f); horizontalLineTo(16f) + verticalLineTo(3f); horizontalLineTo(8f); verticalLineTo(1f); horizontalLineTo(6f) + verticalLineTo(3f); horizontalLineTo(5f); curveTo(3.89f, 3f, 3.01f, 3.9f, 3.01f, 5f) + lineTo(3f, 19f); curveTo(3f, 20.1f, 3.89f, 21f, 5f, 21f); horizontalLineTo(19f) + curveTo(20.1f, 21f, 21f, 20.1f, 21f, 19f); verticalLineTo(5f) + curveTo(21f, 3.9f, 20.1f, 3f, 19f, 3f); close() + moveTo(19f, 19f); horizontalLineTo(5f); verticalLineTo(8f); horizontalLineTo(19f); close() + moveTo(14f, 14f); horizontalLineTo(7f); verticalLineTo(16f); horizontalLineTo(14f); close() + } +}.build() + +private val appsIcon = ImageVector.Builder( + name = "Applications", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, +).apply { + path(fill = SolidColor(Color.Black)) { + moveTo(4f, 4f); horizontalLineTo(8f); verticalLineTo(8f); horizontalLineTo(4f); close() + moveTo(10f, 4f); horizontalLineTo(14f); verticalLineTo(8f); horizontalLineTo(10f); close() + moveTo(16f, 4f); horizontalLineTo(20f); verticalLineTo(8f); horizontalLineTo(16f); close() + moveTo(4f, 10f); horizontalLineTo(8f); verticalLineTo(14f); horizontalLineTo(4f); close() + moveTo(10f, 10f); horizontalLineTo(14f); verticalLineTo(14f); horizontalLineTo(10f); close() + moveTo(16f, 10f); horizontalLineTo(20f); verticalLineTo(14f); horizontalLineTo(16f); close() + moveTo(4f, 16f); horizontalLineTo(8f); verticalLineTo(20f); horizontalLineTo(4f); close() + moveTo(10f, 16f); horizontalLineTo(14f); verticalLineTo(20f); horizontalLineTo(10f); close() + moveTo(16f, 16f); horizontalLineTo(20f); verticalLineTo(20f); horizontalLineTo(16f); close() + } +}.build() + +private val settingsIcon = ImageVector.Builder( + name = "Settings", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, +).apply { + path(fill = SolidColor(Color.Black)) { + moveTo(19.43f, 12.98f) + curveTo(19.47f, 12.66f, 19.5f, 12.34f, 19.5f, 12f) + curveTo(19.5f, 11.66f, 19.47f, 11.34f, 19.43f, 11.02f) + lineTo(21.54f, 9.37f); curveTo(21.73f, 9.22f, 21.78f, 8.95f, 21.66f, 8.73f) + lineTo(19.66f, 5.27f); curveTo(19.54f, 5.05f, 19.27f, 4.97f, 19.05f, 5.05f) + lineTo(16.56f, 6.05f); curveTo(16.04f, 5.65f, 15.48f, 5.32f, 14.87f, 5.07f) + lineTo(14.49f, 2.42f); curveTo(14.46f, 2.18f, 14.25f, 2f, 14f, 2f) + horizontalLineTo(10f); curveTo(9.75f, 2f, 9.54f, 2.18f, 9.51f, 2.42f) + lineTo(9.13f, 5.07f); curveTo(8.52f, 5.32f, 7.96f, 5.66f, 7.44f, 6.05f) + lineTo(4.95f, 5.05f); curveTo(4.72f, 4.96f, 4.46f, 5.05f, 4.34f, 5.27f) + lineTo(2.34f, 8.73f); curveTo(2.21f, 8.95f, 2.27f, 9.22f, 2.46f, 9.37f) + lineTo(4.57f, 11.02f); curveTo(4.53f, 11.34f, 4.5f, 11.67f, 4.5f, 12f) + curveTo(4.5f, 12.33f, 4.53f, 12.66f, 4.57f, 12.98f); lineTo(2.46f, 14.63f) + curveTo(2.27f, 14.78f, 2.22f, 15.05f, 2.34f, 15.27f); lineTo(4.34f, 18.73f) + curveTo(4.46f, 18.95f, 4.73f, 19.03f, 4.95f, 18.95f); lineTo(7.44f, 17.95f) + curveTo(7.96f, 18.35f, 8.52f, 18.68f, 9.13f, 18.93f); lineTo(9.51f, 21.58f) + curveTo(9.54f, 21.82f, 9.75f, 22f, 10f, 22f); horizontalLineTo(14f) + curveTo(14.25f, 22f, 14.46f, 21.82f, 14.49f, 21.58f); lineTo(14.87f, 18.93f) + curveTo(15.48f, 18.68f, 16.04f, 18.34f, 16.56f, 17.95f); lineTo(19.05f, 18.95f) + curveTo(19.28f, 19.04f, 19.54f, 18.95f, 19.66f, 18.73f); lineTo(21.66f, 15.27f) + curveTo(21.78f, 15.05f, 21.73f, 14.78f, 21.54f, 14.63f); close() + moveTo(12f, 15.5f); curveTo(10.07f, 15.5f, 8.5f, 13.93f, 8.5f, 12f) + curveTo(8.5f, 10.07f, 10.07f, 8.5f, 12f, 8.5f); curveTo(13.93f, 8.5f, 15.5f, 10.07f, 15.5f, 12f) + curveTo(15.5f, 13.93f, 13.93f, 15.5f, 12f, 15.5f); close() + } +}.build() + @Composable -fun BottomNavigationBar(navController: NavController) { +fun BottomNavigationBar( + navController: NavController, + modifier: Modifier = Modifier, + floating: Boolean = true, + initialRoute: String? = null, +) { val items = listOf( Screen.Events, Screen.Apps, Screen.Settings ) - NavigationBar(Modifier.height(56.dp)) { - val navBackStackEntry by navController.currentBackStackEntryAsState() - val currentRoute = navBackStackEntry?.destination?.route - - items.forEach { screen -> - val name = stringResource(screen.route) - NavigationBarItem( - icon = { Icon(painterResource(id = screen.icon), contentDescription = name) }, - selected = currentRoute == screen.route.toString(), - onClick = { - navController.navigate(screen.route.toString()) { - popUpTo(navController.graph.startDestinationId) { - saveState = true - } - launchSingleTop = true - restoreState = true - } - }) - } + val navBackStackEntry by navController.currentBackStackEntryAsState() + val currentRoute = navBackStackEntry?.destination?.route + val navigationItems = items.map { screen -> + NavigationItem( + label = stringResource(screen.route), + icon = screen.icon, + ) + } + val selectedRoute = currentRoute ?: initialRoute + val selected = items.indexOfFirst { it.route.toString() == selectedRoute }.coerceAtLeast(0) + + // Scaffold subcomposes the bottom bar before NavHost attaches its graph. Use the declared + // start route for that frame, then recreate the indicator once the first real/restored + // destination appears so cold start and state restoration never animate from a false tab. + key(currentRoute != null) { + MiuixBottomNavigation( + modifier = modifier, + items = navigationItems, + selected = selected, + onClick = { index -> + val screen = items[index] + navController.navigate(screen.route.toString()) { + popUpTo(navController.graph.startDestinationId) { saveState = true } + launchSingleTop = true + restoreState = true + } + }, + floating = floating, + ) } } @Composable private fun Main( startDestination: String, - navContent: () -> NavGraphBuilder.() -> Unit + floatingBottomNav: Boolean = true, + navContent: NavGraphBuilder.() -> Unit ) { val navController = rememberNavController() + val navBackStackEntry by navController.currentBackStackEntryAsState() + val currentRoute = navBackStackEntry?.destination?.route + val swipeRoutes = remember { + listOf( + Screen.Events.route.toString(), + Screen.Apps.route.toString(), + Screen.Settings.route.toString(), + ) + } + val swipeThresholdPx = with(LocalDensity.current) { 72.dp.toPx() } - Theme { - Column( - Modifier - .statusBarsPadding() - .navigationBarsPadding() - .fillMaxSize(), - verticalArrangement = Arrangement.SpaceBetween - ) { - Column(Modifier.weight(1f)) { - NavHost( + Box( + modifier = Modifier + .fillMaxSize() + // Keep the window area behind the dock and the gesture handle painted by the same + // Miuix background as the page. The system navigation bar is transparent in floating + // mode, so this also prevents a theme/default black strip from showing through. + .background(MiuixTheme.colorScheme.background), + ) { + MiuixPageScaffold( + modifier = Modifier.fillMaxSize(), + bottomBar = { + if (!floatingBottomNav) { + BottomNavigationBar( + navController = navController, + modifier = Modifier.fillMaxWidth(), + floating = false, + initialRoute = startDestination, + ) + } + }, + ) { paddingValues -> + NavHost( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .consumeWindowInsets(paddingValues) + // Keep the existing NavHost/back-stack architecture and add a lightweight + // page-level gesture. Vertical scrolling remains owned by each page; this + // detector only starts after horizontal touch-slop and commits on a full swipe. + .pointerInput(currentRoute, swipeThresholdPx) { + var dragDistancePx = 0f + detectHorizontalDragGestures( + onHorizontalDrag = { change, dragAmount -> + change.consume() + dragDistancePx += dragAmount + }, + onDragEnd = { + val targetRoute = routeAfterHorizontalSwipe( + currentRoute = currentRoute, + dragDistancePx = dragDistancePx, + thresholdPx = swipeThresholdPx, + routes = swipeRoutes, + ) + if (targetRoute != null) { + navController.navigate(targetRoute) { + popUpTo(navController.graph.startDestinationId) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + } + dragDistancePx = 0f + }, + onDragCancel = { dragDistancePx = 0f }, + ) + }, + navController = navController, + startDestination = startDestination, + builder = navContent + ) + } + + if (floatingBottomNav) { + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .navigationBarsPadding() + // This is an overlay rather than a Scaffold bottom bar: page content and its + // themed background remain visible around the island, so the dock reads as + // genuinely floating instead of occupying an opaque full-width row. + .padding(bottom = 12.dp), + contentAlignment = Alignment.BottomCenter, + ) { + BottomNavigationBar( navController = navController, - startDestination = startDestination, - builder = navContent() + floating = true, + initialRoute = startDestination, ) } - BottomNavigationBar(navController) } } } @@ -152,17 +392,15 @@ private fun Main( @Composable private fun MainEventsPreview() { Main(Screen.Events.route.toString()) { - { - composable(Screen.Events.route.toString()) { - Column { - val onValueChange: (String) -> Unit = {} - SearchBar(placeholder, onValueChange) - EventListPreview() - } + composable(Screen.Events.route.toString()) { + Column { + val onValueChange: (String) -> Unit = {} + SearchBar(placeholder, onValueChange) + EventListPreview() } - composable(Screen.Apps.route.toString()) { } - composable(Screen.Settings.route.toString()) { } } + composable(Screen.Apps.route.toString()) { } + composable(Screen.Settings.route.toString()) { } } } @@ -173,17 +411,15 @@ private fun MainEventsPreview() { @Composable private fun MainAppsPreview() { Main(Screen.Apps.route.toString()) { - { - composable(Screen.Events.route.toString()) { } - composable(Screen.Apps.route.toString()) { - Column { - val onValueChange: (String) -> Unit = {} - SearchBar(placeholder, onValueChange) - ApplicationListPreview() - } + composable(Screen.Events.route.toString()) { } + composable(Screen.Apps.route.toString()) { + Column { + val onValueChange: (String) -> Unit = {} + SearchBar(placeholder, onValueChange) + ApplicationListPreview() } - composable(Screen.Settings.route.toString()) { } } + composable(Screen.Settings.route.toString()) { } } } @@ -194,11 +430,9 @@ private fun MainAppsPreview() { @Composable private fun MainSettingsPreview() { Main(Screen.Settings.route.toString()) { - { - composable(Screen.Events.route.toString()) { } - composable(Screen.Apps.route.toString()) { } - composable(Screen.Settings.route.toString()) { SettingsPagePreview() } - } + composable(Screen.Events.route.toString()) { } + composable(Screen.Apps.route.toString()) { } + composable(Screen.Settings.route.toString()) { SettingsPagePreview() } } } @@ -209,12 +443,10 @@ private fun MainSettingsPreview() { @Composable private fun MainDialogPreview() { Main(Screen.Events.route.toString()) { - { - composable(Screen.Events.route.toString()) { - EventDetailsDialogPreview() - } - composable(Screen.Apps.route.toString()) { } - composable(Screen.Settings.route.toString()) { } + composable(Screen.Events.route.toString()) { + EventDetailsDialogPreview() } + composable(Screen.Apps.route.toString()) { } + composable(Screen.Settings.route.toString()) { } } -} \ No newline at end of file +} diff --git a/push/src/main/java/top/trumeet/mipushframework/main/MainPageSwipeNavigation.kt b/push/src/main/java/top/trumeet/mipushframework/main/MainPageSwipeNavigation.kt new file mode 100644 index 000000000..c15c25b40 --- /dev/null +++ b/push/src/main/java/top/trumeet/mipushframework/main/MainPageSwipeNavigation.kt @@ -0,0 +1,31 @@ +package top.trumeet.mipushframework.main + +import kotlin.math.abs + +/** + * Returns the destination adjacent to [currentRoute] for a horizontal page swipe. + * + * A negative drag distance is a left swipe (advance to the next page); a positive distance is a + * right swipe (go back to the previous page). Keeping this calculation outside the composable + * makes the gesture contract deterministic and easy to exercise without an Android device. + */ +internal fun routeAfterHorizontalSwipe( + currentRoute: String?, + dragDistancePx: Float, + thresholdPx: Float, + routes: List, +): String? { + if (currentRoute == null || routes.isEmpty()) return null + if (!dragDistancePx.isFinite() || !thresholdPx.isFinite()) return null + if (abs(dragDistancePx) < thresholdPx.coerceAtLeast(0f)) return null + + val currentIndex = routes.indexOf(currentRoute) + if (currentIndex < 0) return null + + val targetIndex = if (dragDistancePx < 0f) { + currentIndex + 1 + } else { + currentIndex - 1 + } + return routes.getOrNull(targetIndex) +} diff --git a/push/src/main/java/top/trumeet/mipushframework/main/RecentEventListPage.kt b/push/src/main/java/top/trumeet/mipushframework/main/RecentEventListPage.kt index f1f908d2e..d4dbde999 100644 --- a/push/src/main/java/top/trumeet/mipushframework/main/RecentEventListPage.kt +++ b/push/src/main/java/top/trumeet/mipushframework/main/RecentEventListPage.kt @@ -6,10 +6,7 @@ import androidx.activity.compose.setContent import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.NavigationBarDefaults -import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.foundation.layout.padding import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable @@ -18,29 +15,35 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.toArgb import androidx.core.view.WindowCompat import top.trumeet.mipushframework.component.SearchBar +import top.trumeet.mipushframework.component.MiuixPageScaffold import top.trumeet.mipushframework.main.subpage.EventList import top.trumeet.ui.theme.Theme +import top.yukonga.miuix.kmp.theme.MiuixTheme class RecentEventListPage : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) WindowCompat.setDecorFitsSystemWindows(window, false) - val packageName = intent.dataString!! + // The page is also a standalone entry point (for example from a + // launcher/debug action), so it may not have the package URI that the + // per-application settings page supplies. An absent URI means show all + // records instead of crashing before Compose is created. + val packageName = intent.dataString ?: "" setContent { Theme { - window.navigationBarColor = MaterialTheme.colorScheme.surfaceColorAtElevation( - NavigationBarDefaults.Elevation - ).toArgb() - Column( - Modifier - .statusBarsPadding() - .fillMaxSize(), - verticalArrangement = Arrangement.SpaceBetween - ) { - var query by rememberSaveable { mutableStateOf("") } - SearchBar("Search...") { query = it } - EventList(query, packageName) + window.navigationBarColor = MiuixTheme.colorScheme.surfaceContainer.toArgb() + MiuixPageScaffold(modifier = Modifier.fillMaxSize()) { paddingValues -> + Column( + Modifier + .padding(paddingValues) + .fillMaxSize(), + verticalArrangement = Arrangement.SpaceBetween + ) { + var query by rememberSaveable { mutableStateOf("") } + SearchBar("Search...") { query = it } + EventList(query, packageName) + } } } } diff --git a/push/src/main/java/top/trumeet/mipushframework/main/RegistrationStateStyle.kt b/push/src/main/java/top/trumeet/mipushframework/main/RegistrationStateStyle.kt index 510e1b65d..9cbcf937c 100644 --- a/push/src/main/java/top/trumeet/mipushframework/main/RegistrationStateStyle.kt +++ b/push/src/main/java/top/trumeet/mipushframework/main/RegistrationStateStyle.kt @@ -12,9 +12,21 @@ object RegistrationStateStyle { val YellowColor = Color(0xffff9800) fun contentOf(app: RegisteredApplication, context: Context): Pair { - val prefix = - if (!app.existServices) context.getString(R.string.mipush_services_not_found) + " - " - else "" + // Keep rows created by older callers compatible with the legacy boolean field. New + // loaders publish the explicit tri-state, while getServiceProbeState() maps a legacy + // `existServices = true` to PRESENT without persisting probe metadata. + val probeState = app.getServiceProbeState() + // A registered event is authoritative. Some ROMs hide or protect the target SDK + // service from package discovery even though the app is already registered; prefixing + // that row with "services not found" incorrectly downgrades a successful registration. + val prefix = when { + app.registeredType == RegisteredApplication.RegisteredType.Registered -> "" + shouldShowMissingServices(app.registeredType, probeState) -> + context.getString(R.string.mipush_services_not_found) + " - " + probeState == RegisteredApplication.ServiceProbeState.UNKNOWN -> + context.getString(R.string.mipush_services_unknown) + " - " + else -> "" + } val color = colorOf(app) return when (app.registeredType) { RegisteredApplication.RegisteredType.Registered -> { @@ -33,20 +45,44 @@ object RegistrationStateStyle { } fun colorOf(app: RegisteredApplication): Color { - return if (!app.existServices) ErrorColor - else when (app.registeredType) { + val probeState = app.getServiceProbeState() + return when (app.registeredType) { RegisteredApplication.RegisteredType.Registered -> { GreenColor } RegisteredApplication.RegisteredType.Unregistered -> { - YellowColor + when (probeState) { + RegisteredApplication.ServiceProbeState.MISSING -> ErrorColor + RegisteredApplication.ServiceProbeState.UNKNOWN -> YellowColor + RegisteredApplication.ServiceProbeState.PRESENT -> YellowColor + } } // RegisteredApplication.RegisteredType.NotRegistered else -> { - Color.Unspecified + when (probeState) { + RegisteredApplication.ServiceProbeState.MISSING -> ErrorColor + RegisteredApplication.ServiceProbeState.UNKNOWN -> YellowColor + RegisteredApplication.ServiceProbeState.PRESENT -> Color.Unspecified + } } } } -} \ No newline at end of file + + /** Missing-service diagnostics apply only to rows that are not already registered. */ + fun shouldShowMissingServices(registeredType: Int, existServices: Boolean): Boolean = + shouldShowMissingServices( + registeredType, + if (existServices) RegisteredApplication.ServiceProbeState.PRESENT + else RegisteredApplication.ServiceProbeState.MISSING, + ) + + /** Only a completed probe can assert that required services are missing. */ + fun shouldShowMissingServices( + registeredType: Int, + serviceProbeState: RegisteredApplication.ServiceProbeState, + ): Boolean = + serviceProbeState == RegisteredApplication.ServiceProbeState.MISSING && + registeredType != RegisteredApplication.RegisteredType.Registered +} diff --git a/push/src/main/java/top/trumeet/mipushframework/main/subpage/ApplicationListFilter.kt b/push/src/main/java/top/trumeet/mipushframework/main/subpage/ApplicationListFilter.kt new file mode 100644 index 000000000..8c76c381d --- /dev/null +++ b/push/src/main/java/top/trumeet/mipushframework/main/subpage/ApplicationListFilter.kt @@ -0,0 +1,31 @@ +package top.trumeet.mipushframework.main.subpage + +import top.trumeet.mipush.provider.entities.RegisteredApplication + +/** + * User-facing application categories. The category is derived only from the registration + * result: a missing SDK service is diagnostic metadata and must not turn a genuinely registered + * application into another category. + */ +enum class ApplicationFilter { + All, + Registered, + Unregistered, + NotRegistered, +} + +fun filterApplicationsForDisplay( + applications: List, + filter: ApplicationFilter, +): List = when (filter) { + ApplicationFilter.All -> applications + ApplicationFilter.Registered -> applications.filter { + it.registeredType == RegisteredApplication.RegisteredType.Registered + } + ApplicationFilter.Unregistered -> applications.filter { + it.registeredType == RegisteredApplication.RegisteredType.Unregistered + } + ApplicationFilter.NotRegistered -> applications.filter { + it.registeredType == RegisteredApplication.RegisteredType.NotRegistered + } +} diff --git a/push/src/main/java/top/trumeet/mipushframework/main/subpage/ApplicationListPage.kt b/push/src/main/java/top/trumeet/mipushframework/main/subpage/ApplicationListPage.kt index b8c62c646..887c46905 100644 --- a/push/src/main/java/top/trumeet/mipushframework/main/subpage/ApplicationListPage.kt +++ b/push/src/main/java/top/trumeet/mipushframework/main/subpage/ApplicationListPage.kt @@ -7,13 +7,11 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.items -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -28,6 +26,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -43,6 +42,12 @@ import top.trumeet.mipushframework.component.RefreshableLazyColumn import top.trumeet.mipushframework.component.iconCache import top.trumeet.mipushframework.main.RegistrationStateStyle import top.trumeet.mipushframework.utils.ParseUtils +import top.yukonga.miuix.kmp.basic.Icon +import top.yukonga.miuix.kmp.basic.Card +import top.yukonga.miuix.kmp.basic.Surface +import top.yukonga.miuix.kmp.basic.Text +import top.yukonga.miuix.kmp.theme.MiuixTheme +import top.yukonga.miuix.kmp.utils.SmoothRoundedCornerShape data class AppInfoForDisplay( val registrationState: Pair, @@ -77,30 +82,92 @@ fun ApplicationList( var isNeedRefresh by rememberSaveable(query) { mutableStateOf(true) } val refreshScope = rememberCoroutineScope { Dispatchers.IO } + var selectedFilter by rememberSaveable(query) { mutableStateOf(ApplicationFilter.All) } val onRefresh: (onRefreshed: () -> Unit) -> Unit = { onRefreshed -> refreshScope.launch { - val applications = getMiPushApplications() - updateInfos(applications, context) - withContext(Dispatchers.Main) { - g_items = applications - isNeedRefresh = false - onRefreshed() - } - applications.res.forEach { - iconCache.cache(it.packageName) + try { + val applications = getMiPushApplications() + updateInfos(applications, context) + withContext(Dispatchers.Main) { + g_items = applications + isNeedRefresh = false + onRefreshed() + } + applications.res.forEach { + iconCache.cache(it.packageName) + } + } catch (t: Throwable) { + withContext(Dispatchers.Main) { onRefreshed() } } } } + val visibleItems = filterApplicationsForDisplay(g_items.res, selectedFilter) + Page { - RefreshableLazyColumn(onRefresh, { false }, onRefresh, isNeedRefresh) { - items(g_items.res, { it.packageName }) { + Column(Modifier.fillMaxSize()) { + ApplicationFilterRow( + selected = selectedFilter, + onSelected = { selectedFilter = it }, + ) + RefreshableLazyColumn( + doRefresh = onRefresh, + isNeedMore = { false }, + doLoadMore = onRefresh, + isNeedRefresh = isNeedRefresh, + modifier = Modifier.weight(1f), + ) { + items(visibleItems, { it.packageName }) { ApplicationItem(it) } item { - val notUseMiPushCount by remember { derivedStateOf { g_items.totalPkg - g_items.res.size } } + val notUseMiPushCount by remember { + derivedStateOf { g_items.totalPkg - g_items.res.size } + } Footer(notUseMiPushCount) } + } + } + } +} + +@Composable +private fun ApplicationFilterRow( + selected: ApplicationFilter, + onSelected: (ApplicationFilter) -> Unit, +) { + val filters = listOf( + ApplicationFilter.All to R.string.application_filter_all, + ApplicationFilter.Registered to R.string.application_filter_registered, + ApplicationFilter.NotRegistered to R.string.application_filter_not_registered, + ApplicationFilter.Unregistered to R.string.application_filter_unregistered, + ) + androidx.compose.foundation.lazy.LazyRow( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(filters.size) { index -> + val (filter, label) = filters[index] + Surface( + modifier = Modifier, + onClick = { onSelected(filter) }, + shape = SmoothRoundedCornerShape(18.dp), + color = if (selected == filter) { + MiuixTheme.colorScheme.primary.copy(alpha = 0.18f) + } else { + MiuixTheme.colorScheme.surfaceContainerHigh + }, + ) { + Text( + text = stringResource(label), + modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp), + color = if (selected == filter) MiuixTheme.colorScheme.primary + else MiuixTheme.colorScheme.onSurface, + style = MiuixTheme.textStyles.footnote1, + ) + } } } } @@ -147,48 +214,61 @@ private fun Footer(notUseMiPushCount: Int) { private fun ApplicationItem(item: RegisteredApplication) { val context = LocalContext.current - Row( - Modifier - .clickable { - EventListPageUtils.startManagePermissions( - context, - item.packageName, - true - ) - } - .padding(10.dp), - verticalAlignment = Alignment.CenterVertically + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 5.dp), ) { - AppIcon(item.packageName, item.appName, Modifier.size(48.dp)) - Spacer(Modifier.width(20.dp)) - Column { - AppInfo(item) - LastReceive(item) + Row( + Modifier + .fillMaxWidth() + .clickable { + EventListPageUtils.startManagePermissions( + context, + item.packageName, + true + ) + } + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + AppIcon(item.packageName, item.appName, Modifier.size(48.dp)) + Spacer(Modifier.width(16.dp)) + Column(Modifier.weight(1f)) { + AppInfo(item) + LastReceive(item) + } } } } @Composable private fun LastReceive(item: RegisteredApplication) { - val info = g_itemsInfo[item.packageName]!! + val info = g_itemsInfo[item.packageName] ?: AppInfoForDisplay( + registrationState = RegistrationStateStyle.contentOf(item, LocalContext.current), + lastReceiveTime = "", + ) Text( info.lastReceiveTime, - style = MaterialTheme.typography.bodyLarge, + style = MiuixTheme.textStyles.body1, ) } @Composable private fun AppInfo(item: RegisteredApplication) { - val info = g_itemsInfo[item.packageName]!! + val info = g_itemsInfo[item.packageName] ?: AppInfoForDisplay( + registrationState = RegistrationStateStyle.contentOf(item, LocalContext.current), + lastReceiveTime = "", + ) Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Text( item.appName, - style = MaterialTheme.typography.bodyLarge, + style = MiuixTheme.textStyles.body1, color = info.registrationState.second ) Text( info.registrationState.first, - style = MaterialTheme.typography.bodyMedium, + style = MiuixTheme.textStyles.body2, color = info.registrationState.second ) } @@ -277,4 +357,4 @@ private fun registeredApplication( ) registeredApplication.existServices = existServices return registeredApplication -} \ No newline at end of file +} diff --git a/push/src/main/java/top/trumeet/mipushframework/main/subpage/ApplicationPageOperation.java b/push/src/main/java/top/trumeet/mipushframework/main/subpage/ApplicationPageOperation.java index fde516db5..2f3fea7f2 100644 --- a/push/src/main/java/top/trumeet/mipushframework/main/subpage/ApplicationPageOperation.java +++ b/push/src/main/java/top/trumeet/mipushframework/main/subpage/ApplicationPageOperation.java @@ -3,6 +3,7 @@ import static top.trumeet.mipush.provider.db.RegisteredApplicationDb.registerApplication; import android.content.Context; +import android.content.SharedPreferences; import android.content.pm.ApplicationInfo; import android.content.pm.PackageInfo; import android.content.pm.PackageManager; @@ -21,13 +22,16 @@ import java.util.Collections; import java.util.Date; import java.util.HashMap; +import java.util.HashSet; import java.util.Iterator; import java.util.List; import java.util.Map; +import java.util.Set; import top.trumeet.common.utils.ElapsedTimer; import top.trumeet.common.utils.Utils; import top.trumeet.mipush.provider.db.EventDb; +import top.trumeet.mipush.provider.db.RegistrationEvidenceResolver; import top.trumeet.mipush.provider.db.RegisteredApplicationDb; import top.trumeet.mipush.provider.entities.RegisteredApplication; import top.trumeet.mipushframework.utils.MiPushManifestChecker; @@ -46,6 +50,10 @@ static public MiPushApplications getMiPushApplications() { miPushApplications.totalPkg = packageInfos.size(); logger.d("[loadApp] get package info ms: %d", timer.restart()); + mergeActiveRegistrationPackages( + Utils.getApplication(), packageInfos, registeredPkgs); + logger.d("[loadApp] merge active registrations ms: %d", timer.restart()); + removePackagesThatNotSupportMiPushServices(packageInfos, registeredPkgs); logger.d("[loadApp] filter not service package ms: %d", timer.restart()); @@ -104,7 +112,10 @@ public static void addApplicationNameIfMissing(List res) } else { application = registerApplication(currentAppPkgName); } - application.existServices = hasMiPushServices(checker, info); + RegisteredApplication.ServiceProbeState probeState = probeMiPushServices(checker, info); + application.setServiceProbeState(probeState); + // Keep the legacy boolean populated for Java/Kotlin callers and older UI code. + application.existServices = probeState == RegisteredApplication.ServiceProbeState.PRESENT; return application; } @@ -120,11 +131,49 @@ public static void removePackagesThatNotSupportMiPushServices(List public static boolean shouldShowInList(PackageInfo info, Map registeredPkgs, MiPushManifestChecker checker) { return isApplicationInstalled(info) && - (isPackageStoredInDB(registeredPkgs, info) || hasMiPushServices(checker, info)); + (isPackageStoredInDB(registeredPkgs, info) || + probeMiPushServices(checker, info) == RegisteredApplication.ServiceProbeState.PRESENT); + } + + /** + * Probe the target application's MiPush SDK services without collapsing a probe failure into + * "missing". PackageManager may hide service metadata for system apps or a ROM may not expose + * the checker implementation; both cases are UNKNOWN and should not be rendered as an error. + */ + public static RegisteredApplication.ServiceProbeState probeMiPushServices( + MiPushManifestChecker checker, PackageInfo info) { + if (checker == null || info == null || info.services == null) { + return RegisteredApplication.ServiceProbeState.UNKNOWN; + } + try { + return mapServiceCheckResult(checker.checkServicesState(info)); + } catch (Throwable ignored) { + // A checker implementation is loaded from the system push package and may fail on + // vendor-specific metadata. Preserve that distinction for the UI. + return RegisteredApplication.ServiceProbeState.UNKNOWN; + } + } + + /** Pure mapping kept separate so probe-state behavior can be tested without Android services. */ + public static RegisteredApplication.ServiceProbeState mapServiceCheckResult( + MiPushManifestChecker.ServiceCheckResult result) { + if (result == null) { + return RegisteredApplication.ServiceProbeState.UNKNOWN; + } + switch (result) { + case PRESENT: + return RegisteredApplication.ServiceProbeState.PRESENT; + case MISSING: + return RegisteredApplication.ServiceProbeState.MISSING; + case UNKNOWN: + default: + return RegisteredApplication.ServiceProbeState.UNKNOWN; + } } + /** Legacy boolean API retained for callers that only need a positive capability check. */ public static boolean hasMiPushServices(MiPushManifestChecker checker, PackageInfo info) { - return checker != null && checker.checkServices(info); + return probeMiPushServices(checker, info) == RegisteredApplication.ServiceProbeState.PRESENT; } public static boolean isPackageStoredInDB(Map registeredPkgs, PackageInfo info) { @@ -161,6 +210,79 @@ public static Map getRegisteredApplicationMap(MiP return registeredPkgs; } + /** + * System-integrated and receiver-only clients may be present in the SDK's active registry + * without declaring the two standard client services. Include those installed packages by + * runtime capability, not by vendor or package-name lists. + */ + static void mergeActiveRegistrationPackages( + Context context, + List packageInfos, + Map registeredPkgs) { + if (context == null || packageInfos == null || registeredPkgs == null) { + return; + } + Set installedPackages = new HashSet<>(); + for (PackageInfo packageInfo : packageInfos) { + if (packageInfo != null && packageInfo.applicationInfo != null + && isApplicationInstalled(packageInfo)) { + installedPackages.add(packageInfo.packageName); + } + } + for (String packageName : getActiveRegistryPackages(context)) { + if (installedPackages.contains(packageName) + && !registeredPkgs.containsKey(packageName)) { + registeredPkgs.put(packageName, registerApplication(packageName)); + } + } + } + + /** Reads the SDK registry once; absence is deliberately not treated as unregistration. */ + static Set getActiveRegistryPackages(Context context) { + Set packages = new HashSet<>(); + if (context == null) { + return packages; + } + SharedPreferences registry = + context.getSharedPreferences("pref_registered_pkg_names", Context.MODE_PRIVATE); + for (Map.Entry entry : registry.getAll().entrySet()) { + Object value = entry.getValue(); + if (!TextUtils.isEmpty(entry.getKey()) + && value != null + && !TextUtils.isEmpty(value.toString())) { + packages.add(entry.getKey()); + } + } + return packages; + } + + /** Snapshot actual delivery times once so list refresh never scans notification history. */ + static Map getLastReceiveTimes(Context context) { + Map receiveTimes = new HashMap<>(); + if (context == null) { + return receiveTimes; + } + SharedPreferences preferences = + context.getSharedPreferences("last_receive_time", Context.MODE_PRIVATE); + for (Map.Entry entry : preferences.getAll().entrySet()) { + if (entry.getValue() instanceof Long) { + receiveTimes.put(entry.getKey(), (Long) entry.getValue()); + } + } + return receiveTimes; + } + + /** Read the persisted SDK state directly; the vendored runtime's cold-start cache is lossy. */ + static Set getExplicitlyUnregisteredPackages(Context context) { + if (context == null) { + return new HashSet<>(); + } + String persisted = context.getSharedPreferences( + "mipush_app_info", Context.MODE_PRIVATE) + .getString("unregistered_pkg_names", ""); + return RegistrationEvidenceResolver.parsePersistedPackageSet(persisted); + } + static void removeApplicationsThatQueryNotMatched(MiPushApplications miPushApplications, String query) { for (final Iterator iterator = miPushApplications.res.iterator(); iterator.hasNext(); ) { RegisteredApplication info = iterator.next(); @@ -229,19 +351,28 @@ static void updateRegisteredApplicationDb(Context context, List activeRegistryPackages = getActiveRegistryPackages(context); + Set explicitlyUnregisteredPackages = + getExplicitlyUnregisteredPackages(context); + Map lastReceiveTimes = getLastReceiveTimes(context); logger.d("[updateApp] get registeredPkgsFromEvents ms: %d", timer.restart()); for (RegisteredApplication application : list) { String pkg = application.getPackageName(); application.appName = Global.ApplicationNameCache() .getAppName(context, pkg).toString(); - if (registrationInfo.registered.contains(pkg)) { - application.setRegisteredType(RegisteredApplication.RegisteredType.Registered); - } else if (registrationInfo.unregistered.contains(pkg)) { - application.setRegisteredType(RegisteredApplication.RegisteredType.Unregistered); - } else { - application.setRegisteredType(RegisteredApplication.RegisteredType.NotRegistered); - } + long lastReceiveTime = lastReceiveTimes.containsKey(pkg) + ? lastReceiveTimes.get(pkg) : 0L; + boolean newerReceiveEvidence = RegistrationEvidenceResolver + .isReceiveEvidenceNewer( + lastReceiveTime, + registrationInfo.latestControlEvidenceTime.get(pkg)); + application.setRegisteredType(RegistrationEvidenceResolver.resolve( + explicitlyUnregisteredPackages.contains(pkg), + activeRegistryPackages.contains(pkg), + registrationInfo.registered.contains(pkg), + newerReceiveEvidence, + !newerReceiveEvidence && registrationInfo.unregistered.contains(pkg))); RegisteredApplicationDb.update(application); } logger.d("[updateApp] update app ms: %d", timer.restart()); @@ -257,4 +388,4 @@ public static class MiPushApplications { public List res = new ArrayList(); public int totalPkg = 0; } -} \ No newline at end of file +} diff --git a/push/src/main/java/top/trumeet/mipushframework/main/subpage/BaseListPage.kt b/push/src/main/java/top/trumeet/mipushframework/main/subpage/BaseListPage.kt index 77a8d104f..243907b8f 100644 --- a/push/src/main/java/top/trumeet/mipushframework/main/subpage/BaseListPage.kt +++ b/push/src/main/java/top/trumeet/mipushframework/main/subpage/BaseListPage.kt @@ -1,17 +1,16 @@ package top.trumeet.mipushframework.main.subpage -import androidx.compose.material3.Surface +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import top.trumeet.mipushframework.component.initIconCache -import top.trumeet.ui.theme.Theme +import top.yukonga.miuix.kmp.basic.Surface @Composable fun Page(content: @Composable () -> Unit) { val context = LocalContext.current initIconCache(context) - Theme { - Surface(content = content) - } -} \ No newline at end of file + Surface(modifier = Modifier.fillMaxSize(), content = content) +} diff --git a/push/src/main/java/top/trumeet/mipushframework/main/subpage/EventListPage.kt b/push/src/main/java/top/trumeet/mipushframework/main/subpage/EventListPage.kt index 9a0866f71..188716008 100644 --- a/push/src/main/java/top/trumeet/mipushframework/main/subpage/EventListPage.kt +++ b/push/src/main/java/top/trumeet/mipushframework/main/subpage/EventListPage.kt @@ -13,10 +13,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.items -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf @@ -32,11 +28,11 @@ import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.DialogProperties import com.elvishew.xlog.XLog import com.xiaomi.xmsf.R import com.xiaomi.xmsf.push.utils.RegSecUtils @@ -48,8 +44,13 @@ import top.trumeet.common.utils.Utils import top.trumeet.mipush.provider.entities.Event import top.trumeet.mipush.provider.event.type.TypeFactory import top.trumeet.mipushframework.component.AppIcon +import top.trumeet.mipushframework.component.MiuixDialog import top.trumeet.mipushframework.component.RefreshableLazyColumn import top.trumeet.mipushframework.component.TextView +import top.yukonga.miuix.kmp.basic.Button +import top.yukonga.miuix.kmp.basic.Text +import top.yukonga.miuix.kmp.basic.Card +import top.yukonga.miuix.kmp.theme.MiuixTheme import java.text.SimpleDateFormat import java.util.Date @@ -130,57 +131,95 @@ private fun EventDetailsDialog( val screenHeight = LocalConfiguration.current.screenHeightDp.dp val targetHeight = screenHeight * 0.9f - AlertDialog( - onDismiss, - { + val show = remember(clickedEvent.id) { mutableStateOf(true) } + var replayStatus by remember(clickedEvent.id) { + mutableStateOf(null) + } + // The service may start or stop while this dialog is open, so only gate the button on the + // immutable record shape here. replayEvent() performs the live service check at click time. + val canReplay = remember(clickedEvent.id) { + EventListPageUtils.getReplayStatus(clickedEvent.event, true) == + EventListPageUtils.ReplayStatus.READY + } + MiuixDialog( + title = "Developer Info", + show = show, + onDismiss = { + onDismiss() + }, + modifier = Modifier.heightIn(Dp.Unspecified, targetHeight), + ) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Button( + modifier = Modifier.weight(1f), + text = stringResource(R.string.action_replay_notification), + enabled = canReplay, + onClick = { + replayStatus = EventListPageUtils.replayEvent(clickedEvent.event) + }, + ) + Button( + modifier = Modifier.weight(1f), + text = stringResource(R.string.action_app_info), + onClick = { + // A notification can arrive before its registration row is persisted. + // Open the same Miuix permission page in that case and let it offer the + // registration action instead of crashing on a missing database record. + show.value = false + onDismiss() + EventListPageUtils.startManagePermissions( + context, + clickedEvent.packageName, + true, + ) + }, + ) + } + replayStatus?.let { status -> + Text( + text = stringResource(replayStatusMessage(status)), + style = MiuixTheme.textStyles.footnote1, + color = MiuixTheme.colorScheme.onSurfaceVariantSummary, + ) + } + TextView(json) Row( Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - TextButton({ - json = - EventListPageUtils.getContent( + Button( + modifier = Modifier.weight(1f), + text = stringResource(R.string.action_configurate), + onClick = { + json = EventListPageUtils.getContent( clickedEvent.event, RegSecUtils.getContainerWithRegSec(clickedEvent.event) ) - }) { Text(stringResource(R.string.action_configurate)) } - - TextButton({ - EventListPageUtils.copyToClipboard(context, json) - }) { Text(stringResource(android.R.string.copy)) } + }, + ) - TextButton({ - EventListPageUtils.mockMessage( - RegSecUtils.getContainerWithRegSec( - clickedEvent.event - ) - ) - }) { Text(stringResource(R.string.action_notify)) } + Button( + modifier = Modifier.weight(1f), + text = stringResource(android.R.string.copy), + onClick = { EventListPageUtils.copyToClipboard(context, json) }, + ) } - }, - title = { - Row( - Modifier - .fillMaxWidth() - .height(36.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text("Developer Info", style = MaterialTheme.typography.titleLarge) + } + } +} - TextButton({ - EventListPageUtils.startManagePermissions( - context, - clickedEvent.packageName - ) - }) { Text(stringResource(R.string.action_app_info)) } - } - }, - text = { - TextView(json) - }, - modifier = Modifier.heightIn(Dp.Unspecified, targetHeight) - ) +private fun replayStatusMessage(status: EventListPageUtils.ReplayStatus): Int = when (status) { + EventListPageUtils.ReplayStatus.DISPATCHED -> R.string.event_replay_dispatched + EventListPageUtils.ReplayStatus.SERVICE_UNAVAILABLE -> R.string.event_replay_service_unavailable + EventListPageUtils.ReplayStatus.INVALID_PAYLOAD -> R.string.event_replay_invalid_payload + EventListPageUtils.ReplayStatus.UNSUPPORTED_EVENT -> R.string.event_replay_unsupported + EventListPageUtils.ReplayStatus.FAILED -> R.string.event_replay_failed + EventListPageUtils.ReplayStatus.READY -> R.string.event_replay_ready } private val g_items = mutableStateListOf() @@ -199,28 +238,49 @@ fun EventList( else mutableStateListOf() } + var hasMore by rememberSaveable(query, packageName) { mutableStateOf(true) } val refreshScope = rememberCoroutineScope { Dispatchers.IO } val doLoadMore: (onRefreshed: () -> Unit) -> Unit = { onRefreshed -> refreshScope.launch { - items.addAll(getEvents(items.isEmpty())) - onRefreshed() + try { + val elements = getEvents(items.isEmpty()) + withContext(Dispatchers.Main) { + val knownIds = items.asSequence().map { it.id }.toHashSet() + val newElements = elements.filter { knownIds.add(it.id) } + items.addAll(newElements) + // A short page is the authoritative end-of-data signal. Empty pages must + // also stop the observer, otherwise an empty list satisfies size - 10 and + // causes an endless load loop. + hasMore = elements.size >= Constants.PAGE_SIZE && newElements.isNotEmpty() + onRefreshed() + } + } catch (t: Throwable) { + withContext(Dispatchers.Main) { onRefreshed() } + } } } var isNeedRefresh by rememberSaveable(query) { mutableStateOf(true) } val doRefresh: (onRefreshed: () -> Unit) -> Unit = { onRefreshed -> refreshScope.launch { - val elements = getEvents(true) - withContext(Dispatchers.Main) { - items.clear() - items.addAll(elements) - isNeedRefresh = false - onRefreshed() + try { + val elements = getEvents(true) + withContext(Dispatchers.Main) { + items.clear() + items.addAll(elements.distinctBy { it.id }) + hasMore = elements.size >= Constants.PAGE_SIZE && elements.isNotEmpty() + isNeedRefresh = false + onRefreshed() + } + } catch (t: Throwable) { + withContext(Dispatchers.Main) { onRefreshed() } } } } - val isNeedMore: (Int) -> Boolean = { it >= items.size - 10 } + val isNeedMore: (Int) -> Boolean = { lastIndex -> + hasMore && items.isNotEmpty() && lastIndex >= (items.size - 10).coerceAtLeast(0) + } RefreshableLazyColumn(doRefresh, isNeedMore, doLoadMore, isNeedRefresh) { items(items, { it.id }) { @@ -233,52 +293,74 @@ fun EventList( private fun EventItem(item: EventInfoForDisplay, onClick: (EventInfoForDisplay) -> Unit) { val disabled = item.configOptions.contains("disable") val alpha = if (disabled) 0.5f else 1f - Row( - Modifier - .clickable { onClick(item) } - .padding(10.dp).alpha(alpha), - verticalAlignment = Alignment.CenterVertically + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 5.dp), ) { - AppIcon(item.packageName, item.appName, modifier = Modifier.size(48.dp)) - Spacer(Modifier.width(20.dp)) - Column { - Row { - ConfigOptions(item) - ChannelInfo(item) - Spacer(Modifier.weight(1f)) - EventReceiveDate(item) + Row( + Modifier + .fillMaxWidth() + .clickable { onClick(item) } + .padding(horizontal = 14.dp, vertical = 12.dp) + .alpha(alpha), + verticalAlignment = Alignment.CenterVertically + ) { + AppIcon(item.packageName, item.appName, modifier = Modifier.size(48.dp)) + Spacer(Modifier.width(16.dp)) + Column(Modifier.weight(1f)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + EventHeader( + item = item, + modifier = Modifier.weight(1f), + ) + Spacer(Modifier.width(8.dp)) + EventReceiveDate(item) + } + EventTitle(item) + EventContent(item) } - EventTitle(item) - EventContent(item) } } } @Composable -private fun ConfigOptions(item: EventInfoForDisplay) { - if (item.configOptions.isNotEmpty()) { - Text(item.configOptions.toString(), style = MaterialTheme.typography.bodySmall) - Spacer(Modifier.width(5.dp)) - } +private fun EventHeader(item: EventInfoForDisplay, modifier: Modifier = Modifier) { + Text( + text = eventHeaderText(item.configOptions, item.channel), + modifier = modifier, + style = MiuixTheme.textStyles.footnote1, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Ellipsis, + ) } -@Composable -private fun ChannelInfo(item: EventInfoForDisplay) { - Text(item.channel, style = MaterialTheme.typography.bodySmall) +internal fun eventHeaderText(configOptions: Set, channel: String): String = when { + configOptions.isEmpty() -> channel + channel.isEmpty() -> configOptions.toString() + else -> "$configOptions $channel" } - @Composable private fun EventReceiveDate(item: EventInfoForDisplay) { val format = receiveDateFormat - Text(format.format(item.receiveDate), style = MaterialTheme.typography.bodySmall) + Text( + text = format.format(item.receiveDate), + style = MiuixTheme.textStyles.footnote1, + maxLines = 1, + softWrap = false, + ) } @Composable private fun EventTitle(item: EventInfoForDisplay) { Text( item.title, - style = MaterialTheme.typography.bodyLarge, + style = MiuixTheme.textStyles.body1, ) } @@ -286,7 +368,7 @@ private fun EventTitle(item: EventInfoForDisplay) { private fun EventContent(item: EventInfoForDisplay) { Text( item.content, - style = MaterialTheme.typography.bodyMedium, + style = MiuixTheme.textStyles.body2, ) } @@ -362,6 +444,34 @@ fun EventListPreview() { } } +@Preview( + name = "Long event header", + showBackground = true, + widthDp = 320, + heightDp = 220, + fontScale = 1.3f, +) +@Composable +private fun EventItemLongHeaderPreview() { + Page { + EventItem( + item = EventInfoForDisplay( + id = 1, + packageName = "preview.application", + configOptions = linkedSetOf( + "configuration-option-without-break-opportunities", + "secondary-option", + ), + channel = "这是一个用于验证窄屏省略行为的超长通知频道标题", + receiveDate = date(2026, 8, 25), + title = "通知标题仍在独立行显示", + content = "右侧完整时间应保持单行,左侧头部使用省略号。", + ), + onClick = {}, + ) + } +} + private fun date(year: Int, month: Int, date: Int) = Date(year - 1900, month - 1, date) diff --git a/push/src/main/java/top/trumeet/mipushframework/main/subpage/EventListPageUtils.java b/push/src/main/java/top/trumeet/mipushframework/main/subpage/EventListPageUtils.java index 9786d9ab9..56894f672 100644 --- a/push/src/main/java/top/trumeet/mipushframework/main/subpage/EventListPageUtils.java +++ b/push/src/main/java/top/trumeet/mipushframework/main/subpage/EventListPageUtils.java @@ -3,6 +3,7 @@ import android.content.ClipboardManager; import android.content.Context; import android.content.Intent; +import android.text.TextUtils; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -22,6 +23,7 @@ import com.xiaomi.xmsf.push.utils.Configurations; import com.xiaomi.xmsf.push.utils.RegSecUtils; import com.xiaomi.xmsf.utils.ConvertUtils; +import com.xiaomi.push.service.XMPushService; import org.apache.thrift.TBase; @@ -38,6 +40,27 @@ public class EventListPageUtils { private final Context context; + /** + * Result of the explicit replay action exposed from an event's detail dialog. + * + *

Keeping this result separate from the notification pipeline is intentional: replay is a + * user initiated diagnostic action and must never alter the normal receive/notify path.

+ */ + public enum ReplayStatus { + /** The event is not a notification (for example, a registration or command record). */ + UNSUPPORTED_EVENT, + /** The record has no payload, or its payload cannot be decoded. */ + INVALID_PAYLOAD, + /** The push service is not alive, so there is no safe dispatcher to invoke. */ + SERVICE_UNAVAILABLE, + /** The record passed pre-flight checks and can be replayed. */ + READY, + /** The replay request was handed to the push message processor. */ + DISPATCHED, + /** The processor rejected the replay request synchronously. */ + FAILED, + } + public EventListPageUtils(Context context) { this.context = context; } @@ -78,6 +101,65 @@ public static void mockMessage(XmPushActionContainer containerWithRegSec) { XMPushServiceAbility.xmPushService, containerWithRegSec.deepCopy()); } + /** + * Returns whether an event represents a message that can be shown again as a notification. + * Registration, command and diagnostic records deliberately remain display-only. + */ + public static boolean isReplayableEvent(@Nullable Event event) { + if (event == null) { + return false; + } + return event.getType() == Event.Type.SendMessage + || event.getType() == Event.Type.Notification; + } + + /** + * Pure pre-flight check used by the UI and unit tests. The service argument is supplied by + * the caller so this method remains deterministic and does not read process-global state. + */ + public static @NonNull ReplayStatus getReplayStatus( + @Nullable Event event, boolean serviceAvailable) { + if (!isReplayableEvent(event)) { + return ReplayStatus.UNSUPPORTED_EVENT; + } + if (event.getPayload() == null || event.getPayload().length == 0) { + return ReplayStatus.INVALID_PAYLOAD; + } + if (!serviceAvailable) { + return ReplayStatus.SERVICE_UNAVAILABLE; + } + return ReplayStatus.READY; + } + + /** + * Replays one stored notification through the existing mock-message entry point. + * + *

This method is intentionally defensive because records from older database schemas may + * contain a missing or malformed payload, and the service can stop while the dialog is open. + * A failed replay is reported to the caller instead of crashing the Compose page.

+ */ + public static @NonNull ReplayStatus replayEvent(@Nullable Event event) { + XMPushService service = XMPushServiceAbility.xmPushService; + ReplayStatus preflight = getReplayStatus(event, service != null); + if (preflight != ReplayStatus.READY) { + return preflight; + } + + try { + XmPushActionContainer container = RegSecUtils.getContainerWithRegSec(event); + if (container == null) { + return ReplayStatus.INVALID_PAYLOAD; + } + return MockMIPushMessage.mockProcessMIPushMessage(service, container.deepCopy()) + ? ReplayStatus.DISPATCHED + : ReplayStatus.FAILED; + } catch (Throwable ignored) { + // Replay is an optional diagnostic action. Never let a decoder or service race take + // down the event details dialog. + return ReplayStatus.FAILED; + } + } + public static @NonNull String getContent(Event event, XmPushActionContainer containerWithRegSec) { try { XmPushActionContainer newContainer = containerWithRegSec.deepCopy(); @@ -133,7 +215,9 @@ public static void startManagePermissions(Context context, String packageName) { } public static void startManagePermissions(Context context, String packageName, boolean IGNORE_NOT_REGISTERED) { - // Issue: This currently allows overlapping opens. + if (context == null || TextUtils.isEmpty(packageName)) { + return; + } Intent intent = new Intent(context, ApplicationInfoPage.class) .putExtra(ApplicationInfoPage.EXTRA_PACKAGE_NAME, packageName); if (IGNORE_NOT_REGISTERED) { @@ -195,4 +279,4 @@ private String getStatusDescriptionByEvent(@NonNull Event item) { return ""; } -} \ No newline at end of file +} diff --git a/push/src/main/java/top/trumeet/mipushframework/main/subpage/SettingsPage.kt b/push/src/main/java/top/trumeet/mipushframework/main/subpage/SettingsPage.kt index 89af5abbb..a7cf6a4c7 100644 --- a/push/src/main/java/top/trumeet/mipushframework/main/subpage/SettingsPage.kt +++ b/push/src/main/java/top/trumeet/mipushframework/main/subpage/SettingsPage.kt @@ -1,5 +1,7 @@ package top.trumeet.mipushframework.main.subpage +import android.Manifest +import android.app.Activity import android.content.Context import android.content.Intent import android.content.IntentFilter @@ -7,15 +9,18 @@ import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TextField +import androidx.compose.foundation.layout.heightIn +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -23,47 +28,93 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import com.nihility.Global import com.nihility.InternalMessenger import com.xiaomi.push.service.XMPushServiceMessenger import com.xiaomi.xmsf.R import com.xiaomi.xmsf.SettingUtils +import com.xiaomi.xmsf.push.utils.ConfigurationDiagnosticsSnapshot +import com.xiaomi.xmsf.push.utils.Configurations import top.trumeet.common.utils.Utils import top.trumeet.mipushframework.MainPageOperation import top.trumeet.mipushframework.component.SettingsGroup import top.trumeet.mipushframework.component.SettingsItem +import top.trumeet.mipushframework.component.MiuixActionButton +import top.trumeet.mipushframework.component.MiuixInput import top.trumeet.mipushframework.main.AdvancedSettingsPage import top.trumeet.mipushframework.main.HelpPage +import top.trumeet.mipushframework.utils.NotificationPermissionController +import top.trumeet.mipushframework.utils.NotificationPermissionPolicy import top.trumeet.ui.theme.Theme +import top.yukonga.miuix.kmp.basic.Surface +import top.yukonga.miuix.kmp.basic.Text +import top.yukonga.miuix.kmp.theme.MiuixTheme @Composable -fun Settings() { - Theme { - Surface( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()), - color = MaterialTheme.colorScheme.background - ) { - SettingsScreen() - } +fun Settings( + floatingBottomNav: Boolean = true, + onFloatingBottomNavChange: ((Boolean) -> Unit)? = null, +) { + Surface( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + color = MiuixTheme.colorScheme.background + ) { + SettingsScreen( + floatingBottomNav = floatingBottomNav, + onFloatingBottomNavChange = onFloatingBottomNavChange, + ) } } @Composable -private fun SettingsScreen() { +private fun SettingsScreen( + floatingBottomNav: Boolean, + onFloatingBottomNavChange: ((Boolean) -> Unit)?, +) { Column { + AppearanceBlock( + floatingBottomNav = floatingBottomNav, + onFloatingBottomNavChange = onFloatingBottomNavChange, + ) ServiceConfigurationBlock() DebugBlock() AboutBlock() + if (floatingBottomNav) { + Spacer(Modifier.height(100.dp)) + } + } +} + +@Composable +private fun AppearanceBlock( + floatingBottomNav: Boolean, + onFloatingBottomNavChange: ((Boolean) -> Unit)?, +) { + SettingsGroup(title = stringResource(R.string.settings_appearance)) { + SettingsItem( + title = stringResource(R.string.settings_floating_bottom_navigation), + summary = stringResource(R.string.settings_floating_bottom_navigation_summary), + checked = floatingBottomNav, + onClick = { + onFloatingBottomNavChange?.invoke(!floatingBottomNav) + } + ) } } @Composable private fun ServiceConfigurationBlock() { val context = LocalContext.current + var diagnosticsRefreshToken by remember { mutableStateOf(0) } SettingsGroup(title = stringResource(R.string.settings_service_setting)) { SettingsItem( @@ -73,28 +124,91 @@ private fun ServiceConfigurationBlock() { context.startActivity(Intent(context, AdvancedSettingsPage::class.java)) } - SetConfigurationsDirectory() + NotificationPermissionItem() + SetConfigurationsDirectory { + diagnosticsRefreshToken += 1 + } + ConfigurationDiagnosticsItem( + refreshToken = diagnosticsRefreshToken, + onRefresh = { diagnosticsRefreshToken += 1 }, + ) SetXMPPServer(context) } } @Composable -@OptIn(ExperimentalMaterial3Api::class) +private fun NotificationPermissionItem() { + val context = LocalContext.current + val activity = context as? Activity + val lifecycleOwner = LocalLifecycleOwner.current + var status by remember { + mutableStateOf( + activity?.let { NotificationPermissionController.status(it) } + ?: NotificationPermissionPolicy.Status.BLOCKED + ) + } + val launcher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission() + ) { + NotificationPermissionController.markRequested(context) + if (activity != null) { + status = NotificationPermissionController.status(activity) + } + } + + DisposableEffect(lifecycleOwner, activity) { + val observer = androidx.lifecycle.LifecycleEventObserver { _, event -> + if (event == androidx.lifecycle.Lifecycle.Event.ON_RESUME && activity != null) { + status = NotificationPermissionController.status(activity) + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } + + val summary = when (status) { + NotificationPermissionPolicy.Status.NOT_REQUIRED -> + stringResource(R.string.settings_notification_permission_not_required) + NotificationPermissionPolicy.Status.GRANTED -> + stringResource(R.string.settings_notification_permission_granted) + NotificationPermissionPolicy.Status.REQUESTABLE -> + stringResource(R.string.settings_notification_permission_request) + NotificationPermissionPolicy.Status.DENIED_CAN_ASK_AGAIN -> + stringResource(R.string.settings_notification_permission_denied) + NotificationPermissionPolicy.Status.BLOCKED -> + stringResource(R.string.settings_notification_permission_blocked) + } + + SettingsItem( + title = stringResource(R.string.settings_notification_permission), + summary = summary, + ) { + when (status) { + NotificationPermissionPolicy.Status.REQUESTABLE, + NotificationPermissionPolicy.Status.DENIED_CAN_ASK_AGAIN -> { + NotificationPermissionController.markRequested(context) + launcher.launch(Manifest.permission.POST_NOTIFICATIONS) + } + else -> NotificationPermissionController.openNotificationSettings(context) + } + } +} + +@Composable private fun SetXMPPServer(context: Context) { var currentXMPPServer by remember { mutableStateOf("") } - object : InternalMessenger(context) { - init { - register(IntentFilter(XMPushServiceMessenger.IntentSetConnectionStatus)) - addListener { intent: Intent -> - val host = intent.getStringExtra("host") - if (host.isNullOrEmpty()) { - return@addListener + DisposableEffect(context) { + val messenger = object : InternalMessenger(context) { + init { + register(IntentFilter(XMPushServiceMessenger.IntentSetConnectionStatus)) + addListener { intent: Intent -> + val host = intent.getStringExtra("host") + if (!host.isNullOrEmpty()) currentXMPPServer = host } - currentXMPPServer = host + send(Intent(XMPushServiceMessenger.IntentGetConnectionStatus)) } - - send(Intent(XMPushServiceMessenger.IntentGetConnectionStatus)) } + onDispose { messenger.close() } } var text by remember { mutableStateOf(SettingUtils.getXMPPServer(context) ?: "") } SettingsItem(title = stringResource(R.string.settings_XMPP_server), @@ -102,7 +216,7 @@ private fun SetXMPPServer(context: Context) { "\nSet: [${SettingUtils.getXMPPServer(context) ?: ""}]" + "\nCurrent: [$currentXMPPServer]", confirmButton = { dismiss: () -> Unit -> - TextButton(onClick = { + MiuixActionButton(onClick = { SettingUtils.setXMPPServer(context, text) SettingUtils.sendXMPPReconnectRequest(context) currentXMPPServer = text @@ -115,39 +229,173 @@ private fun SetXMPPServer(context: Context) { text = "" }, content = { - TextField( + MiuixInput( value = text, onValueChange = { text = it }, - placeholder = { Text(SettingUtils.getXMPPServerHint()) }, + label = SettingUtils.getXMPPServerHint(), singleLine = true ) }) } @Composable -private fun SetConfigurationsDirectory() { +private fun SetConfigurationsDirectory(onConfigurationChanged: () -> Unit = {}) { val context = LocalContext.current - var selectedDirectoryUri by remember { - mutableStateOf( - SettingUtils.getConfigurationDirectory( - context - ) - ) - } - val openDocumentTreeLauncher = rememberLauncherForActivityResult( + val launcher = rememberLauncherForActivityResult( contract = ActivityResultContracts.OpenDocumentTree() ) { uri -> if (uri != null) { - selectedDirectoryUri = uri + context.contentResolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ) SettingUtils.setConfigurationDirectory(context, uri) + Global.ConfigCenter().loadConfigurations(context) + onConfigurationChanged() } } + SettingsItem( title = stringResource(R.string.settings_configuration_directory), - summary = selectedDirectoryUri?.toString() + summary = SettingUtils.getConfigurationDirectory(context)?.path ) { - openDocumentTreeLauncher.launch(null) // 启动文件选择器 + launcher.launch(null) + } +} + +/** + * Shows configuration-reference diagnostics as a regular Miuix settings item. + * + * The snapshot is read from the configuration singleton only from a coroutine launched by an + * explicit refresh key. This keeps SAF timestamp checks and configuration I/O out of Compose + * recomposition, while still updating after a directory selection or a manual re-check. + */ +@Composable +private fun ConfigurationDiagnosticsItem( + refreshToken: Int, + onRefresh: () -> Unit, +) { + var snapshot by remember { + mutableStateOf(ConfigurationDiagnosticsSnapshot.notConfigured()) + } + var refreshing by remember { mutableStateOf(false) } + + LaunchedEffect(refreshToken) { + refreshing = true + snapshot = withContext(Dispatchers.IO) { + Configurations.getInstance().getDiagnosticsSnapshot() + } + refreshing = false + } + + val summary = when { + refreshing -> stringResource(R.string.settings_configuration_diagnostics_checking) + snapshot.getStatus() == ConfigurationDiagnosticsSnapshot.Status.NOT_CONFIGURED -> + stringResource(R.string.settings_configuration_diagnostics_not_configured) + snapshot.getStatus() == ConfigurationDiagnosticsSnapshot.Status.FAILED -> + stringResource(R.string.settings_configuration_diagnostics_failed) + snapshot.getUnresolvedReferences().isEmpty() -> + stringResource(R.string.settings_configuration_diagnostics_ready) + else -> + stringResource( + R.string.settings_configuration_diagnostics_missing, + snapshot.getUnresolvedReferences().size, + ) + } + + SettingsItem( + title = stringResource(R.string.settings_configuration_diagnostics), + summary = summary, + confirmButton = { dismiss -> + MiuixActionButton(onClick = { + onRefresh() + dismiss() + }) { + Text(stringResource(R.string.settings_configuration_diagnostics_refresh)) + } + }, + content = { + if (refreshing) { + Text( + text = stringResource(R.string.settings_configuration_diagnostics_checking), + style = MiuixTheme.textStyles.body2, + ) + } else { + ConfigurationDiagnosticsDetails(snapshot) + } + }, + ) +} + +@Composable +private fun ConfigurationDiagnosticsDetails(snapshot: ConfigurationDiagnosticsSnapshot) { + when (snapshot.getStatus()) { + ConfigurationDiagnosticsSnapshot.Status.NOT_CONFIGURED -> Text( + text = stringResource(R.string.settings_configuration_diagnostics_not_configured), + style = MiuixTheme.textStyles.body2, + ) + ConfigurationDiagnosticsSnapshot.Status.FAILED -> Text( + text = stringResource(R.string.settings_configuration_diagnostics_failed), + style = MiuixTheme.textStyles.body2, + ) + ConfigurationDiagnosticsSnapshot.Status.READY -> { + val references = snapshot.getUnresolvedReferences() + if (references.isEmpty()) { + Text( + text = stringResource(R.string.settings_configuration_diagnostics_ready), + style = MiuixTheme.textStyles.body2, + ) + } else { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 420.dp), + ) { + items(references) { reference -> + Column(modifier = Modifier.padding(vertical = 6.dp)) { + DiagnosticValue( + label = stringResource( + R.string.settings_configuration_diagnostics_source, + ), + value = sanitizeDiagnosticValue(reference.getSourceName()), + ) + DiagnosticValue( + label = stringResource( + R.string.settings_configuration_diagnostics_owner, + ), + value = sanitizeDiagnosticValue(reference.getOwnerKey()), + ) + DiagnosticValue( + label = stringResource( + R.string.settings_configuration_diagnostics_reference, + ), + value = sanitizeDiagnosticValue(reference.getReference()), + ) + } + } + } + } + } + } +} + +@Composable +private fun DiagnosticValue(label: String, value: String) { + Text( + text = "$label: $value", + style = MiuixTheme.textStyles.body2, + color = MiuixTheme.colorScheme.onSurface, + ) +} + +/** Keep diagnostics safe and bounded even if a malformed config contains control characters. */ +private fun sanitizeDiagnosticValue(value: String, maxLength: Int = 240): String { + val sanitized = buildString { + value.forEach { character -> + append(if (character.isISOControl()) '\uFFFD' else character) + } } + return if (sanitized.length <= maxLength) sanitized else sanitized.take(maxLength - 1) + '\u2026' } @Composable @@ -203,4 +451,3 @@ fun SettingsPagePreview() { Utils.context = LocalContext.current Settings() } - diff --git a/push/src/main/java/top/trumeet/mipushframework/utils/MiPushManifestChecker.java b/push/src/main/java/top/trumeet/mipushframework/utils/MiPushManifestChecker.java index 10559f378..a7cd3fb35 100644 --- a/push/src/main/java/top/trumeet/mipushframework/utils/MiPushManifestChecker.java +++ b/push/src/main/java/top/trumeet/mipushframework/utils/MiPushManifestChecker.java @@ -25,6 +25,13 @@ public class MiPushManifestChecker { private static final String TAG = MiPushManifestChecker.class.getSimpleName(); + /** Result of checking the target app's required MiPush SDK services. */ + public enum ServiceCheckResult { + PRESENT, + MISSING, + UNKNOWN + } + private final Context context; private final Class manifestChecker; private final Method checkServicesMethod; @@ -78,7 +85,12 @@ public boolean checkReceivers(String packageName) { return result; } - public boolean checkServices(PackageInfo pkgInfo) { + /** + * Check services while preserving the distinction between an invalid manifest and a probe + * failure. The old boolean API could only return false and consequently made PackageManager + * or vendor-runtime failures look like a missing SDK service. + */ + public ServiceCheckResult checkServicesState(PackageInfo pkgInfo) { try { Map configServiceProcessMap = new HashMap<>(); Map requiredServicesMap = new HashMap<>(); @@ -116,17 +128,23 @@ public boolean checkServices(PackageInfo pkgInfo) { if (configServiceProcessMap.containsKey(PushConstants.XM_SERVICE_CLASS_NAME_JAR) && configServiceProcessMap.containsKey(PushConstants.PUSH_SERVICE_CLASS_NAME_JAR) && !TextUtils.equals(configServiceProcessMap.get(PushConstants.XM_SERVICE_CLASS_NAME_JAR), configServiceProcessMap.get(PushConstants.PUSH_SERVICE_CLASS_NAME_JAR))) { throw new ManifestChecker.IllegalManifestException(String.format("\"%1$s\" and \"%2$s\" must be running in the same process.", PushConstants.XM_SERVICE_CLASS_NAME_JAR, PushConstants.PUSH_SERVICE_CLASS_NAME_JAR)); } - return true; + return ServiceCheckResult.PRESENT; } catch (Throwable e) { - if (!isIllegalManifestException(e)) { - Log.e(TAG, "checkServices", e); - } else { + if (isIllegalManifestException(e)) { Log.w(TAG, "checkServices: " + pkgInfo.packageName + "," + e.getMessage()); + return ServiceCheckResult.MISSING; + } else { + Log.e(TAG, "checkServices", e); + return ServiceCheckResult.UNKNOWN; } - return false; } } + /** Legacy boolean API retained for callers that only need a positive capability check. */ + public boolean checkServices(PackageInfo pkgInfo) { + return checkServicesState(pkgInfo) == ServiceCheckResult.PRESENT; + } + private static boolean isIllegalManifestException(Throwable e) { if (e instanceof InvocationTargetException) { e = ((InvocationTargetException) e).getTargetException(); diff --git a/push/src/main/java/top/trumeet/mipushframework/utils/NotificationPermissionController.java b/push/src/main/java/top/trumeet/mipushframework/utils/NotificationPermissionController.java new file mode 100644 index 000000000..942a58168 --- /dev/null +++ b/push/src/main/java/top/trumeet/mipushframework/utils/NotificationPermissionController.java @@ -0,0 +1,105 @@ +package top.trumeet.mipushframework.utils; + +import android.Manifest; +import android.app.Activity; +import android.content.ActivityNotFoundException; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.pm.PackageManager; +import android.net.Uri; +import android.os.Build; +import android.provider.Settings; + +import androidx.annotation.NonNull; +import androidx.core.app.ActivityCompat; +import androidx.core.content.ContextCompat; + +/** Android adapter around {@link NotificationPermissionPolicy}. */ +public final class NotificationPermissionController { + private static final String PREFS_NAME = "notification_permission_state"; + private static final String KEY_REQUESTED = "post_notifications_requested"; + + private NotificationPermissionController() { + } + + @NonNull + public static NotificationPermissionPolicy.Status status(@NonNull Activity activity) { + boolean granted = Build.VERSION.SDK_INT < NotificationPermissionPolicy.RUNTIME_PERMISSION_SDK + || ContextCompat.checkSelfPermission(activity, Manifest.permission.POST_NOTIFICATIONS) + == PackageManager.PERMISSION_GRANTED; + boolean requestedBefore = preferences(activity).getBoolean(KEY_REQUESTED, false); + boolean shouldShowRationale = Build.VERSION.SDK_INT + >= NotificationPermissionPolicy.RUNTIME_PERMISSION_SDK + && ActivityCompat.shouldShowRequestPermissionRationale( + activity, Manifest.permission.POST_NOTIFICATIONS); + return NotificationPermissionPolicy.evaluate( + Build.VERSION.SDK_INT, granted, requestedBefore, shouldShowRationale); + } + + public static boolean shouldAutoRequest(@NonNull Activity activity) { + return NotificationPermissionPolicy.shouldAutoRequest(status(activity)); + } + + public static void markRequested(@NonNull Context context) { + preferences(context).edit().putBoolean(KEY_REQUESTED, true).apply(); + } + + public static void openNotificationSettings(@NonNull Context context) { + String packageName = context.getPackageName(); + Intent notificationSettings = new Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS) + .putExtra(Settings.EXTRA_APP_PACKAGE, packageName) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + Intent applicationDetails = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + .setData(Uri.parse("package:" + packageName)) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + + PackageManager packageManager; + try { + packageManager = context.getPackageManager(); + } catch (SecurityException ignored) { + return; + } + + boolean notificationSettingsResolvable; + try { + notificationSettingsResolvable = notificationSettings.resolveActivity(packageManager) != null; + } catch (ActivityNotFoundException | SecurityException ignored) { + notificationSettingsResolvable = false; + } + + Intent preferred = NotificationPermissionPolicy.chooseSettingsRoute(notificationSettingsResolvable) + == NotificationPermissionPolicy.SettingsRoute.APP_NOTIFICATION_SETTINGS + ? notificationSettings + : applicationDetails; + if (tryStartActivity(context, packageManager, preferred)) { + return; + } + + // HyperOS variants can report a resolver and still reject the launch. Keep a + // package-details fallback so tapping the row can never crash or strand the user. + if (preferred != applicationDetails) { + tryStartActivity(context, packageManager, applicationDetails); + } + } + + private static boolean tryStartActivity( + @NonNull Context context, + @NonNull PackageManager packageManager, + @NonNull Intent intent) { + try { + if (intent.resolveActivity(packageManager) == null) { + return false; + } + context.startActivity(intent); + return true; + } catch (ActivityNotFoundException | SecurityException ignored) { + return false; + } + } + + @NonNull + private static SharedPreferences preferences(@NonNull Context context) { + return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + } +} diff --git a/push/src/main/java/top/trumeet/mipushframework/utils/NotificationPermissionPolicy.java b/push/src/main/java/top/trumeet/mipushframework/utils/NotificationPermissionPolicy.java new file mode 100644 index 000000000..fad4b4d40 --- /dev/null +++ b/push/src/main/java/top/trumeet/mipushframework/utils/NotificationPermissionPolicy.java @@ -0,0 +1,65 @@ +package top.trumeet.mipushframework.utils; + +/** + * Pure decision model for Android 13+ notification permission UX. + * + *

Keeping the policy free of Android framework calls makes the first-run and + * settings behavior deterministic and unit-testable.

+ */ +public final class NotificationPermissionPolicy { + public static final int RUNTIME_PERMISSION_SDK = 33; + + private NotificationPermissionPolicy() { + } + + public enum Status { + NOT_REQUIRED, + GRANTED, + REQUESTABLE, + DENIED_CAN_ASK_AGAIN, + BLOCKED + } + + /** + * Destination used when the user needs to repair notification access from settings. + * + *

This is deliberately a pure value so the Android intent construction and launch + * fallback can be tested without creating an Android {@code Context}.

+ */ + public enum SettingsRoute { + APP_NOTIFICATION_SETTINGS, + APPLICATION_DETAILS_SETTINGS + } + + /** + * Selects the most specific settings destination available on the device. + */ + public static SettingsRoute chooseSettingsRoute(boolean notificationSettingsResolvable) { + return notificationSettingsResolvable + ? SettingsRoute.APP_NOTIFICATION_SETTINGS + : SettingsRoute.APPLICATION_DETAILS_SETTINGS; + } + + public static Status evaluate( + int sdkInt, + boolean granted, + boolean requestedBefore, + boolean shouldShowRationale) { + if (sdkInt < RUNTIME_PERMISSION_SDK) { + return Status.NOT_REQUIRED; + } + if (granted) { + return Status.GRANTED; + } + if (!requestedBefore) { + return Status.REQUESTABLE; + } + return shouldShowRationale + ? Status.DENIED_CAN_ASK_AGAIN + : Status.BLOCKED; + } + + public static boolean shouldAutoRequest(Status status) { + return status == Status.REQUESTABLE; + } +} diff --git a/push/src/main/java/top/trumeet/mipushframework/utils/PermissionUtils.java b/push/src/main/java/top/trumeet/mipushframework/utils/PermissionUtils.java index 25a888689..8779829c7 100644 --- a/push/src/main/java/top/trumeet/mipushframework/utils/PermissionUtils.java +++ b/push/src/main/java/top/trumeet/mipushframework/utils/PermissionUtils.java @@ -46,8 +46,8 @@ public static boolean lunchAppOps(Context context, String permission, CharSequen Intent intent = new Intent(Intent.ACTION_SHOW_APP_INFO) .setClassName("rikka.appops", "rikka.appops.appdetail.AppDetailActivity") .putExtra("rikka.appops.intent.extra.USER_HANDLE", Utils.myUid()) - .putExtra("rikka.appops.intent.extra.PACKAGE_NAME", Constants.SERVICE_APP_NAME) - .setData(Uri.parse("package:" + Constants.SERVICE_APP_NAME)) + .putExtra("rikka.appops.intent.extra.PACKAGE_NAME", context.getPackageName()) + .setData(Uri.parse("package:" + context.getPackageName())) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); context.startActivity(intent); Toast.makeText(context, tips, Toast.LENGTH_LONG).show(); @@ -58,8 +58,9 @@ public static boolean lunchAppOps(Context context, String permission, CharSequen } public static boolean allowPermission(String permission) { + Context context = Utils.getApplication(); return ShellUtils.exec("appops set --user " + Utils.myUid() + - " " + Constants.SERVICE_APP_NAME + " " + permission + + " " + context.getPackageName() + " " + permission + " " + AppOpsManager.MODE_ALLOWED); } } diff --git a/push/src/main/java/top/trumeet/mipushframework/wizard/RequestPermissionPage.kt b/push/src/main/java/top/trumeet/mipushframework/wizard/RequestPermissionPage.kt index 6ff28fc74..bc4809153 100644 --- a/push/src/main/java/top/trumeet/mipushframework/wizard/RequestPermissionPage.kt +++ b/push/src/main/java/top/trumeet/mipushframework/wizard/RequestPermissionPage.kt @@ -19,13 +19,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowForward -import androidx.compose.material3.BottomAppBar -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.NavigationBarDefaults -import androidx.compose.material3.Text -import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.MutableState @@ -33,6 +26,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLifecycleOwner @@ -48,6 +42,11 @@ import top.trumeet.mipushframework.wizard.permission.PermissionInfo import top.trumeet.mipushframework.wizard.permission.RequestIgnoreBatteryOptimizationsPermissionInfo import top.trumeet.mipushframework.wizard.permission.UsageStatsPermissionInfo import top.trumeet.ui.theme.Theme +import top.yukonga.miuix.kmp.basic.Icon +import top.yukonga.miuix.kmp.basic.IconButton +import top.yukonga.miuix.kmp.basic.Surface +import top.yukonga.miuix.kmp.basic.Text +import top.yukonga.miuix.kmp.theme.MiuixTheme class RequestPermissionPage : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -55,9 +54,7 @@ class RequestPermissionPage : ComponentActivity() { WindowCompat.setDecorFitsSystemWindows(window, false) setContent { Theme { - window.navigationBarColor = MaterialTheme.colorScheme.surfaceColorAtElevation( - NavigationBarDefaults.Elevation - ).toArgb() + window.navigationBarColor = MiuixTheme.colorScheme.surfaceContainer.toArgb() PermissionMainPage() } } @@ -155,7 +152,7 @@ private fun Description(description: String) { Row { MarkdownView( description, - textSize = MaterialTheme.typography.bodyLarge.fontSize.value, + textSize = MiuixTheme.textStyles.body1.fontSize.value, modifier = Modifier .align(Alignment.Bottom) .padding(16.dp) @@ -167,14 +164,14 @@ private fun Description(description: String) { private fun Title(title: String) { Row( Modifier - .background(MaterialTheme.colorScheme.primaryContainer) + .background(MiuixTheme.colorScheme.primaryContainer) .fillMaxWidth() .fillMaxHeight(0.4f) ) { Text( title, - style = MaterialTheme.typography.headlineLarge, - color = MaterialTheme.colorScheme.onPrimaryContainer, + style = MiuixTheme.textStyles.title1, + color = MiuixTheme.colorScheme.onPrimaryContainer, modifier = Modifier .align(Alignment.Bottom) .padding(16.dp) @@ -186,7 +183,10 @@ private fun Title(title: String) { private fun BottomBar( currentItem: MutableState, permissions: List ) { - BottomAppBar(modifier = Modifier.height(56.dp)) { + Surface( + modifier = Modifier.height(64.dp), + color = MiuixTheme.colorScheme.surfaceContainer, + ) { Row( modifier = Modifier.fillMaxWidth(), @@ -194,7 +194,9 @@ private fun BottomBar( verticalAlignment = Alignment.CenterVertically, ) { IconButton( - onClick = { currentItem.value-- }, enabled = currentItem.value > 0 + onClick = { currentItem.value-- }, + enabled = currentItem.value > 0, + backgroundColor = Color.Transparent, ) { Icon( imageVector = Icons.Default.ArrowBack, contentDescription = "上一项" @@ -202,17 +204,20 @@ private fun BottomBar( } val operator = permissions[currentItem.value].permissionOperator - IconButton(onClick = { + IconButton( + onClick = { if (operator.isPermissionGranted()) { currentItem.value++ } else { operator.requestPermission() } - }) { + }, + backgroundColor = Color.Transparent, + ) { Icon( imageVector = Icons.Default.ArrowForward, contentDescription = "下一项" ) } } } -} \ No newline at end of file +} diff --git a/push/src/main/java/top/trumeet/ui/theme/Color.kt b/push/src/main/java/top/trumeet/ui/theme/Color.kt index 007f22be1..85cffae29 100644 --- a/push/src/main/java/top/trumeet/ui/theme/Color.kt +++ b/push/src/main/java/top/trumeet/ui/theme/Color.kt @@ -2,10 +2,5 @@ package top.trumeet.ui.theme import androidx.compose.ui.graphics.Color -val Purple80 = Color(0xFFD0BCFF) -val PurpleGrey80 = Color(0xFFCCC2DC) -val Pink80 = Color(0xFFEFB8C8) - -val Purple40 = Color(0xFF6650a4) -val PurpleGrey40 = Color(0xFF625b71) -val Pink40 = Color(0xFF7D5260) \ No newline at end of file +internal val MiuixBlueLight = Color(0xFF3482FF) +internal val MiuixBlueDark = Color(0xFF277AF7) diff --git a/push/src/main/java/top/trumeet/ui/theme/Theme.kt b/push/src/main/java/top/trumeet/ui/theme/Theme.kt index 32138b4dc..d93a97740 100644 --- a/push/src/main/java/top/trumeet/ui/theme/Theme.kt +++ b/push/src/main/java/top/trumeet/ui/theme/Theme.kt @@ -1,58 +1,96 @@ package top.trumeet.ui.theme -import android.app.Activity import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.ColorScheme import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.darkColorScheme as materialDarkColorScheme import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme -import androidx.compose.material3.lightColorScheme +import androidx.compose.material3.lightColorScheme as materialLightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext +import top.yukonga.miuix.kmp.theme.Colors +import top.yukonga.miuix.kmp.theme.MiuixTheme +import top.yukonga.miuix.kmp.theme.darkColorScheme +import top.yukonga.miuix.kmp.theme.lightColorScheme -private val DarkColorScheme = darkColorScheme( -// primary = Purple80, -// secondary = PurpleGrey80, -// tertiary = Pink80 +private val DarkColorScheme: Colors = darkColorScheme( + primary = MiuixBlueDark, + primaryVariant = MiuixBlueDark, + onTertiaryContainer = MiuixBlueDark, ) -private val LightColorScheme = lightColorScheme( -// primary = Purple40, -// secondary = PurpleGrey40, -// tertiary = Pink40 - - /* Other default colors to override - background = Color(0xFFFFFBFE), - surface = Color(0xFFFFFBFE), - onPrimary = Color.White, - onSecondary = Color.White, - onTertiary = Color.White, - onBackground = Color(0xFF1C1B1F), - onSurface = Color(0xFF1C1B1F), - */ +private val LightColorScheme: Colors = lightColorScheme( + primary = MiuixBlueLight, + primaryVariant = MiuixBlueLight, + onTertiaryContainer = MiuixBlueLight, ) +fun materialToMiuixColors( + materialColors: ColorScheme, + baseColors: Colors +): Colors { + return baseColors.copy( + primary = materialColors.primary, + primaryVariant = materialColors.primary, + onPrimary = materialColors.onPrimary, + primaryContainer = materialColors.primaryContainer, + onPrimaryContainer = materialColors.onPrimaryContainer, + secondary = materialColors.secondary, + onSecondary = materialColors.onSecondary, + secondaryContainer = materialColors.secondaryContainer, + onSecondaryContainer = materialColors.onSecondaryContainer, + background = materialColors.background, + onBackground = materialColors.onBackground, + surface = materialColors.surfaceContainerLow, + onSurface = materialColors.onSurface, + surfaceVariant = materialColors.surfaceVariant, + surfaceContainer = materialColors.surfaceContainer, + onSurfaceContainer = materialColors.onSurface, + surfaceContainerHigh = materialColors.surfaceContainerHigh, + onSurfaceContainerHigh = materialColors.onSurface, + surfaceContainerHighest = materialColors.surfaceContainerHighest, + onSurfaceContainerHighest = materialColors.onSurface, + onSurfaceSecondary = materialColors.onSurfaceVariant, + onSurfaceVariantSummary = materialColors.onSurfaceVariant, + onSurfaceVariantActions = materialColors.onSurfaceVariant, + outline = materialColors.outline, + dividerLine = materialColors.outlineVariant, + onTertiaryContainer = materialColors.onTertiaryContainer, + ) +} + @Composable fun Theme( - darkTheme: Boolean = isSystemInDarkTheme(), - // Dynamic color is available on Android 12+ - dynamicColor: Boolean = true, - content: @Composable () -> Unit + darkTheme: Boolean = isSystemInDarkTheme(), + // Android 12+ supplies its system accent while Miuix keeps MIUI surface tokens. + dynamicColor: Boolean = true, + content: @Composable () -> Unit ) { - val colorScheme = when { - dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { - val context = LocalContext.current - if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + val context = LocalContext.current + val materialColors = if (dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } else { + if (darkTheme) { + materialDarkColorScheme(primary = MiuixBlueDark) + } else { + materialLightColorScheme(primary = MiuixBlueLight) } - - darkTheme -> DarkColorScheme - else -> LightColorScheme + } + val configuration = LocalConfiguration.current + val baseColors = if (darkTheme) DarkColorScheme else LightColorScheme + val colors = remember(context, configuration, darkTheme, dynamicColor, materialColors) { + materialToMiuixColors(materialColors, baseColors) } - MaterialTheme( - colorScheme = colorScheme, - typography = Typography, - content = content - ) -} \ No newline at end of file + MaterialTheme(colorScheme = materialColors) { + MiuixTheme( + colors = colors, + textStyles = AppTextStyles, + content = content, + ) + } +} diff --git a/push/src/main/java/top/trumeet/ui/theme/Type.kt b/push/src/main/java/top/trumeet/ui/theme/Type.kt index ef28a102d..330adb855 100644 --- a/push/src/main/java/top/trumeet/ui/theme/Type.kt +++ b/push/src/main/java/top/trumeet/ui/theme/Type.kt @@ -1,35 +1,24 @@ package top.trumeet.ui.theme - -import androidx.compose.material3.Typography import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.em import androidx.compose.ui.unit.sp +import top.yukonga.miuix.kmp.theme.defaultTextStyles -// Set of Material typography styles to start with -val Typography = Typography( -// bodyLarge = TextStyle( -// fontFamily = FontFamily.Default, -// fontWeight = FontWeight.Normal, -// fontSize = 16.sp, -// lineHeight = 24.sp, -// letterSpacing = 0.5.sp -// ) - /* Other default text styles to override - titleLarge = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 22.sp, - lineHeight = 28.sp, - letterSpacing = 0.sp - ), - labelSmall = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Medium, - fontSize = 11.sp, - lineHeight = 16.sp, - letterSpacing = 0.5.sp - ) - */ -) \ No newline at end of file +internal val AppTextStyles = defaultTextStyles( + main = TextStyle(fontSize = 17.sp, lineHeight = 1.2f.em), + paragraph = TextStyle(fontSize = 17.sp, lineHeight = 1.35f.em), + body1 = TextStyle(fontSize = 16.sp, lineHeight = 1.25f.em), + body2 = TextStyle(fontSize = 14.sp, lineHeight = 1.25f.em), + button = TextStyle(fontSize = 17.sp, fontWeight = FontWeight.Medium), + footnote1 = TextStyle(fontSize = 13.sp, lineHeight = 1.25f.em), + footnote2 = TextStyle(fontSize = 11.sp, lineHeight = 1.2f.em), + headline1 = TextStyle(fontSize = 17.sp, fontWeight = FontWeight.Medium), + headline2 = TextStyle(fontSize = 16.sp, fontWeight = FontWeight.Medium), + subtitle = TextStyle(fontSize = 14.sp, fontWeight = FontWeight.Medium), + title1 = TextStyle(fontSize = 32.sp, fontWeight = FontWeight.SemiBold), + title2 = TextStyle(fontSize = 24.sp, fontWeight = FontWeight.SemiBold), + title3 = TextStyle(fontSize = 20.sp, fontWeight = FontWeight.SemiBold), + title4 = TextStyle(fontSize = 18.sp, fontWeight = FontWeight.Medium), +) diff --git a/push/src/main/res/drawable/notification_focus_point_current.xml b/push/src/main/res/drawable/notification_focus_point_current.xml new file mode 100644 index 000000000..46a31a365 --- /dev/null +++ b/push/src/main/res/drawable/notification_focus_point_current.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/push/src/main/res/drawable/notification_focus_point_done.xml b/push/src/main/res/drawable/notification_focus_point_done.xml new file mode 100644 index 000000000..233b850f0 --- /dev/null +++ b/push/src/main/res/drawable/notification_focus_point_done.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/push/src/main/res/drawable/notification_focus_point_inactive.xml b/push/src/main/res/drawable/notification_focus_point_inactive.xml new file mode 100644 index 000000000..f20b06be4 --- /dev/null +++ b/push/src/main/res/drawable/notification_focus_point_inactive.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/push/src/main/res/drawable/notification_focus_track.xml b/push/src/main/res/drawable/notification_focus_track.xml new file mode 100644 index 000000000..96f7c1331 --- /dev/null +++ b/push/src/main/res/drawable/notification_focus_track.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/push/src/main/res/layout/notification_focus_portable.xml b/push/src/main/res/layout/notification_focus_portable.xml new file mode 100644 index 000000000..ac640ed60 --- /dev/null +++ b/push/src/main/res/layout/notification_focus_portable.xml @@ -0,0 +1,148 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/push/src/main/res/layout/notification_focus_portable_compact.xml b/push/src/main/res/layout/notification_focus_portable_compact.xml new file mode 100644 index 000000000..e707fdb28 --- /dev/null +++ b/push/src/main/res/layout/notification_focus_portable_compact.xml @@ -0,0 +1,122 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/push/src/main/res/values-night/colors.xml b/push/src/main/res/values-night/colors.xml new file mode 100644 index 000000000..2450835b4 --- /dev/null +++ b/push/src/main/res/values-night/colors.xml @@ -0,0 +1,8 @@ + + + #FFFFFFFF + #CCFFFFFF + #FFFF9D4D + #55FFFFFF + #99FFFFFF + diff --git a/push/src/main/res/values-zh/strings.xml b/push/src/main/res/values-zh/strings.xml index e2e3ee59e..d80fdb8fa 100644 --- a/push/src/main/res/values-zh/strings.xml +++ b/push/src/main/res/values-zh/strings.xml @@ -76,6 +76,19 @@ 这是一个测试内容 + 模拟焦点通知 + 发送携带 MIUI 官方焦点参数的测试通知,用于兼容性检查。 + 焦点通知 + 浮动配送进度更新 + 商家接单 + 骑手配送 + 已送达 + 等待接单 + HyperOS 焦点协议 + SystemUI 已提供焦点协议 v%1$d,将转发焦点参数与图片 Bundle。 + 系统未声明可选的原生协议,仍会转发原始焦点参数,并保留安全的标准通知回退。 + 焦点通知测试 + 焦点参数生成时间: 应用注册时显示通知 在配置加载完成后显示加载成功的配置文件 启用调试模式 @@ -83,6 +96,9 @@ 事件列表中展示所有事件 推送服务保活 显示一个前台通知来保活推送服务,可以通过禁用相应通知渠道来隐藏该通知 + 息屏闹钟策略 + 可使用精确唤醒,优先保证消息及时到达。 + 正在使用省电的非精确回退;点按可检查精确闹钟权限。 开启冰箱(IceBox)自动解冻支持 点击通知栏时自动解冻冻结的App。 @@ -153,6 +169,13 @@ 修改权限(保存 ----> 应用信息 + 手动弹出通知 + 可以再次弹出这条通知。 + 已请求再次弹出通知。 + 推送服务未运行,请启动服务后重试。 + 这条记录没有有效的通知内容。 + 只有通知记录可以再次弹出。 + 通知再次弹出失败。 权限 @@ -163,16 +186,34 @@ 注册时显示通知 需要在设置-高级配置中打开全局注册通知开关 允许并自动注册 - 阻止注册 询问(默认) 增强 + 外观 + 悬浮底部导航栏 + 以悬浮圆角岛的形式展示底部导航栏 服务配置 高级配置 + 通知权限 + 此系统版本由系统通知设置统一管理。 + 已允许,可显示焦点通知与普通通知。 + 点按以允许 Android 13 及以上版本显示通知。 + 权限已被拒绝,点按可再次请求。 + 权限已被禁止,点按打开系统通知设置。 进入高级配置界面 设置配置目录 + 配置引用检查 + 正在检查配置引用… + 尚未选择配置目录。 + 未发现缺失的配置引用。 + 发现 %1$d 个缺失的配置引用,相关规则可能不会生效。 + 无法读取或解析配置目录。 + 重新检查 + 来源 + 所属配置 + 引用 设置 XMPP 服务器 设置 发送/接收 消息的服务器 @@ -207,64 +248,38 @@ 注册异常 - Push 应用需要被加入“电池优化”白名单才能正常运行 - 未注册 - 已忽略 %1$s 个未使用系统推送的应用 - - 应用程序注册问题解决方案[帮助] 应用注册问题。 - ]]> - - Magisk 全局伪装模块Magisk 单应用伪装 可以增加注册几率。如有疑问,欢迎参阅 应用程序注册问题解决方案[帮助] 应用注册问题。 - ]]> - [帮助] 注册异常。 - ]]> - 未注册 注册异常 - - 调试 - 无法收到推送 - 注册异常 - 注册列表为空 - + [帮助] 注册异常。]]> + 未注册 + 未注册 + 已忽略 %1$s 个无 MiPush SDK 的应用 + 应用程式注册问题解决方案[帮助] 应用注册问题。]]> + Magisk 全局伪装模块Magisk 单应用伪装模块 以增加成功率。另见:应用程式注册问题解决方案[帮助] 应用注册问题。]]> + 立即注册 + 重新注册 + 这会清除目标应用现有的推送注册信息,并请求它重新向推送服务注册。目标应用可能会被打开或重启。 + 搜索 + 刷新 + 回到顶部 + 全部 + 已注册 + 有问题 + 未注册 + 最近接收时间: + 未检测到标准 MiPush SDK 服务 + 无法确认推送服务状态 + 尝试强制注册所有应用 + 推送服务需要加入“电池优化”白名单才能正常运行。 + 聚合同一会话的所有通知 + 同一会话通知将被折叠聚合,非会话通知不会被聚合 + 点击会话通知时清除该会话的所有通知 + 将“透传”消息以通知形式展示 管理通知 - 控制本应用所发通知的声音、重要度等细节。 - - 使用情况访问 API(性能好,推荐) - 辅助模式(兼容性更好) - - + 控制应用的通知声音、重要程度等。 - - 0 - 1 - @string/register_type_ask @string/register_type_allow @string/register_type_deny - 堆叠同一会话的所有通知 - 同一会话的所有通知将会堆叠为一组,这会导致非会话通知无法堆叠 - 会话通知被点击时清空整个会话通知组 - 将透传消息作为通知显示 - 通知渠道 - 删除 - 设置 - 复制 ID - - 通知消息 - 透传消息 - 发出通知 - 执行配置 - 搜索 - 最近推送: - 找不到服务 - 尝试强制注册所有应用 - 忽略电池优化 - 您需要授予“忽略电池优化”权限,否则您可能将无法在后台运行mipush服务。 - - - \ No newline at end of file + diff --git a/push/src/main/res/values/colors.xml b/push/src/main/res/values/colors.xml new file mode 100644 index 000000000..288fb9311 --- /dev/null +++ b/push/src/main/res/values/colors.xml @@ -0,0 +1,8 @@ + + + #FF1D1B20 + #991D1B20 + #FF6200 + #33000000 + #66000000 + diff --git a/push/src/main/res/values/configs.xml b/push/src/main/res/values/configs.xml deleted file mode 100644 index 61c70f17f..000000000 --- a/push/src/main/res/values/configs.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - android - de.robv.android.xposed.installer - com.xiaomi.xmsf - com.tencent.mm - top.trumeet.mipush - - - - - com.google.android - - \ No newline at end of file diff --git a/push/src/main/res/values/strings.xml b/push/src/main/res/values/strings.xml index c68763172..007ce4d2c 100644 --- a/push/src/main/res/values/strings.xml +++ b/push/src/main/res/values/strings.xml @@ -51,6 +51,7 @@ Config + Experimental Foreground app detection mode @@ -80,6 +81,13 @@ Tap to send a simulated test message. This is Title This is Content + Simulate focus notification + Send a notification carrying the official MIUI focus payload for compatibility testing. + HyperOS focus protocol + SystemUI protocol v%1$d is available. Focus payloads and image bundles will be forwarded. + SystemUI did not advertise the optional native protocol. The original focus payload will still be forwarded with a safe standard fallback. + Focus notification test + Focus payload generated at Used for the alternate foreground application detection mode, higher compatibility, but poor performance. Notify On App Register Show Loaded File After Configurations Loaded @@ -88,6 +96,9 @@ Display all events Keep MiPush alive show a foreground notification to keep alive, you can hide it by disable notification channel + Idle alarm policy + Exact wake-up is available for the most reliable delivery. + Using the power-saving inexact fallback. Tap to review exact-alarm access. @@ -170,7 +181,21 @@ Please grant the Run in the background or wake up permissions. Edit Permissions (Save by ----> App Info send notification + Show again run configuration + Ready to show this notification again. + Notification replay requested. + Push service is not running. Start it and try again. + This record has no valid notification payload. + Only notification records can be shown again. + Could not replay this notification. + + Focus updates + Floating progress updates + Merchant accepted + Courier delivering + Delivered + Waiting for acceptance Permissions @@ -189,18 +214,36 @@ Please grant the Run in the background or wake up permissions. @string/register_type_deny - Notification Channels Delete Setting Copy ID + Appearance + Floating bottom navigation bar + Display navigation bar as a floating island with rounded corners Enhances Push configs Advanced + Notification permission + Managed by Android notification settings on this system version. + Allowed. Focus and standard notifications can be displayed. + Tap to allow notifications on Android 13 or later. + Permission was denied. Tap to ask again. + Permission is blocked. Tap to open system notification settings. Advanced configurations interface Change configuration directory + Configuration reference check + Checking configuration references… + No configuration directory selected. + No missing configuration references. + %1$d missing configuration reference(s); related rules may not take effect. + Unable to read or parse the configuration directory. + Re-check + Source + Owner + Reference Set XMPP server Set send/receive message server @@ -208,7 +251,6 @@ Please grant the Run in the background or wake up permissions. Get debugging logs Tap to sharing logs file, submit it to your issue if you can. - View recent events Recent events @@ -241,6 +283,9 @@ Registration is determined and initiated by the application and generally begins Registration is determined and initiated by the application and generally begins automatically when the application is launched. Some apps are only registered when finding a XiaoMi devices, you can try Magisk global-level module or Magisk apps-level module to increase success. See also: 应用程式注册问题解决方案 and [帮助] 应用注册问题. ]]> Unregistered + Register now + Register again + This clears the target app\'s existing push registration data and asks it to register with the push service again. The target app may be opened or restarted. Group all notifications for the same session the same session notifications will be group, and the non-session notifications will not be group @@ -280,9 +325,16 @@ This could be the application doing a reverse registration, or the registration clear all notifications of session when the notification of session was clicked show \"pass through\" message as notification Search + Refresh + Back to top + All + Registered + Has problems + Not registered last receive: - Services Not Found + Standard MiPush SDK services not detected + Unable to verify push service status Try to force register all applications - \ No newline at end of file + diff --git a/push/src/main/res/values/styles.xml b/push/src/main/res/values/styles.xml index 69dca83dc..30233fa75 100644 --- a/push/src/main/res/values/styles.xml +++ b/push/src/main/res/values/styles.xml @@ -36,5 +36,12 @@ false + + diff --git a/push/src/test/java/com/nihility/utils/NotificationReplayMarkerTest.java b/push/src/test/java/com/nihility/utils/NotificationReplayMarkerTest.java new file mode 100644 index 000000000..0e06503de --- /dev/null +++ b/push/src/test/java/com/nihility/utils/NotificationReplayMarkerTest.java @@ -0,0 +1,47 @@ +package com.nihility.utils; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertTrue; + +import com.xiaomi.xmpush.thrift.PushMetaInfo; +import com.xiaomi.xmpush.thrift.XmPushActionContainer; + +import org.junit.Test; + +import java.util.HashMap; + +public class NotificationReplayMarkerTest { + @Test + public void replayMarkerLivesOnlyInDeepCopyAndCanBeStripped() { + XmPushActionContainer original = new XmPushActionContainer(); + PushMetaInfo metaInfo = new PushMetaInfo(); + metaInfo.extra = new HashMap<>(); + metaInfo.extra.put("business", "value"); + original.metaInfo = metaInfo; + + XmPushActionContainer replay = NotificationReplayMarker.markedCopy(original); + + assertFalse(NotificationReplayMarker.isMarked(original)); + assertTrue(NotificationReplayMarker.isMarked(replay)); + assertNotSame(original.getMetaInfo().getExtra(), replay.getMetaInfo().getExtra()); + + XmPushActionContainer target = + NotificationReplayMarker.copyWithoutMarker(replay); + assertTrue(NotificationReplayMarker.isMarked(replay)); + assertFalse(NotificationReplayMarker.isMarked(target)); + assertEquals("value", target.getMetaInfo().getExtra().get("business")); + } + + @Test + public void markerCreatesMetadataMapWithoutMutatingOriginal() { + XmPushActionContainer original = new XmPushActionContainer(); + original.metaInfo = new PushMetaInfo(); + + XmPushActionContainer replay = NotificationReplayMarker.markedCopy(original); + + assertEquals(null, original.getMetaInfo().getExtra()); + assertTrue(NotificationReplayMarker.isMarked(replay)); + } +} diff --git a/push/src/test/java/com/xiaomi/push/sdk/TargetSdkClickDispatcherTest.java b/push/src/test/java/com/xiaomi/push/sdk/TargetSdkClickDispatcherTest.java new file mode 100644 index 000000000..9f67c4803 --- /dev/null +++ b/push/src/test/java/com/xiaomi/push/sdk/TargetSdkClickDispatcherTest.java @@ -0,0 +1,173 @@ +package com.xiaomi.push.sdk; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import com.xiaomi.push.service.PushConstants; + +import org.junit.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +public class TargetSdkClickDispatcherTest { + private static final String PACKAGE = "example.target"; + + @Test + public void enabledExportedServiceWinsBeforeReceiver() { + FakeSource source = new FakeSource( + candidate(PACKAGE, "SdkService", true, true), + Collections.singletonList(candidate(PACKAGE, "Receiver", true, true))); + + TargetSdkClickDispatcher.Capability selected = + TargetSdkClickDispatcher.selectCapability(PACKAGE, source); + + assertEquals(TargetSdkClickDispatcher.Kind.SERVICE, selected.kind); + assertEquals("SdkService", selected.candidate.className); + } + + @Test + public void receiverIsUsedWhenServiceIsDisabledOrPrivate() { + for (TargetSdkClickDispatcher.Candidate service : Arrays.asList( + candidate(PACKAGE, "Disabled", false, true), + candidate(PACKAGE, "Private", true, false), + null)) { + FakeSource source = new FakeSource(service, + Collections.singletonList(candidate(PACKAGE, "Receiver", true, true))); + + TargetSdkClickDispatcher.Capability selected = + TargetSdkClickDispatcher.selectCapability(PACKAGE, source); + + assertEquals(TargetSdkClickDispatcher.Kind.RECEIVER, selected.kind); + assertEquals("Receiver", selected.candidate.className); + assertEquals(PushConstants.MIPUSH_ACTION_NEW_MESSAGE, source.requestedAction); + } + } + + @Test + public void selectorRejectsDisabledPrivateAndCrossPackageReceivers() { + FakeSource source = new FakeSource(null, Arrays.asList( + candidate(PACKAGE, "Disabled", false, true), + candidate(PACKAGE, "Private", true, false), + candidate("other.package", "Foreign", true, true))); + + assertNull(TargetSdkClickDispatcher.selectCapability(PACKAGE, source)); + } + + @Test + public void receiverChoiceIsDeterministic() { + FakeSource source = new FakeSource(null, Arrays.asList( + candidate(PACKAGE, "z.Last", true, true), + candidate(PACKAGE, "a.First", true, true))); + + TargetSdkClickDispatcher.Capability selected = + TargetSdkClickDispatcher.selectCapability(PACKAGE, source); + + assertEquals("a.First", selected.candidate.className); + } + + @Test + public void deliveryAcceptanceDoesNotUseNavigationTerminology() { + assertTrue(TargetSdkClickDispatcher.DispatchResult.SERVICE_DELIVERY_ACCEPTED + .isAccepted()); + assertTrue(TargetSdkClickDispatcher.DispatchResult.BROADCAST_DELIVERY_ACCEPTED + .isAccepted()); + assertFalse(TargetSdkClickDispatcher.DispatchResult.UNAVAILABLE.isAccepted()); + assertFalse(TargetSdkClickDispatcher.DispatchResult.FAILED.isAccepted()); + } + + @Test + public void privateAndReplayRoutesDispatchSdkBeforeOpeningLauncher() { + assertEquals(NotificationClickHandoffPolicy.Action.DISPATCH_SDK_FIRST, + NotificationClickHandoffPolicy.initialAction(false, true)); + assertEquals(NotificationClickHandoffPolicy.Action.DISPATCH_SDK_FIRST, + NotificationClickHandoffPolicy.initialAction(true, false)); + assertEquals(NotificationClickHandoffPolicy.Action.DISPATCH_SDK_FIRST, + NotificationClickHandoffPolicy.initialAction(true, true)); + assertEquals(NotificationClickHandoffPolicy.Action.START_DIRECT_TARGET, + NotificationClickHandoffPolicy.initialAction(false, false)); + } + + @Test + public void acceptedSdkDeliveryWaitsForNavigationWithoutStartingLauncher() { + assertEquals(NotificationClickHandoffPolicy.Action.WAIT_FOR_TARGET, + NotificationClickHandoffPolicy.afterSdkDispatch(true, false)); + assertEquals(NotificationClickHandoffPolicy.Action.FINISH, + NotificationClickHandoffPolicy.afterSdkDispatch(true, true)); + } + + @Test + public void rejectedSdkDeliveryFallsBackImmediately() { + assertEquals(NotificationClickHandoffPolicy.Action.START_FALLBACK, + NotificationClickHandoffPolicy.afterSdkDispatch(false, false)); + } + + @Test + public void navigationProbeUsesLauncherOnlyAfterBoundedTimeout() { + assertEquals(NotificationClickHandoffPolicy.Action.WAIT_FOR_TARGET, + NotificationClickHandoffPolicy.afterNavigationProbe(false, false, true)); + assertEquals(NotificationClickHandoffPolicy.Action.FINISH, + NotificationClickHandoffPolicy.afterNavigationProbe(true, false, true)); + assertEquals(NotificationClickHandoffPolicy.Action.START_FALLBACK, + NotificationClickHandoffPolicy.afterNavigationProbe(false, true, true)); + } + + @Test + public void navigationProbeDoesNotLaunchBehindKeyguard() { + assertEquals(NotificationClickHandoffPolicy.Action.FINISH, + NotificationClickHandoffPolicy.afterNavigationProbe(false, true, false)); + } + + @Test + public void oneShotDeliveryIdentitySeparatesPackageNotificationAndCapability() { + int baseline = TargetSdkClickDispatcher.deliveryRequestCode( + "example.a", 42, TargetSdkClickDispatcher.Kind.SERVICE); + assertFalse(baseline == TargetSdkClickDispatcher.deliveryRequestCode( + "example.b", 42, TargetSdkClickDispatcher.Kind.SERVICE)); + assertFalse(baseline == TargetSdkClickDispatcher.deliveryRequestCode( + "example.a", 43, TargetSdkClickDispatcher.Kind.SERVICE)); + assertFalse(baseline == TargetSdkClickDispatcher.deliveryRequestCode( + "example.a", 42, TargetSdkClickDispatcher.Kind.RECEIVER)); + } + + @Test + public void receiverPermissionUsesTargetPackageContract() { + assertEquals("example.target.permission.MIPUSH_RECEIVE", + TargetSdkClickDispatcher.receiverPermission(PACKAGE)); + } + + private static TargetSdkClickDispatcher.Candidate candidate( + String packageName, String className, boolean enabled, boolean exported) { + return new TargetSdkClickDispatcher.Candidate( + packageName, className, enabled, exported); + } + + private static final class FakeSource + implements TargetSdkClickDispatcher.CapabilitySource { + final TargetSdkClickDispatcher.Candidate service; + final List receivers; + String requestedAction; + + FakeSource(TargetSdkClickDispatcher.Candidate service, + List receivers) { + this.service = service; + this.receivers = receivers; + } + + @Override + public TargetSdkClickDispatcher.Candidate service( + String targetPackage, String serviceClass) { + return service; + } + + @Override + public List receivers( + String targetPackage, String action) { + requestedAction = action; + return receivers; + } + } +} diff --git a/push/src/test/java/com/xiaomi/push/service/ConversationIconFallbackTest.java b/push/src/test/java/com/xiaomi/push/service/ConversationIconFallbackTest.java new file mode 100644 index 000000000..6c323616c --- /dev/null +++ b/push/src/test/java/com/xiaomi/push/service/ConversationIconFallbackTest.java @@ -0,0 +1,37 @@ +package com.xiaomi.push.service; + +import org.junit.Test; +import org.junit.BeforeClass; + +import com.elvishew.xlog.XLog; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * Regression coverage for conversation-avatar fallback selection. The + * resolver must only be consulted when a configuration did not provide a + * sender URI; it must never turn an explicit URI failure into a different + * account lookup. + */ +public class ConversationIconFallbackTest { + @BeforeClass + public static void initializeLogging() { + XLog.init(); + } + + @Test + public void missingSenderUriUsesLocalApplicationFallback() { + assertTrue(MyMIPushNotificationHelper.shouldUseApplicationIconFallback(null)); + assertTrue(MyMIPushNotificationHelper.shouldUseApplicationIconFallback("")); + assertTrue(MyMIPushNotificationHelper.shouldUseApplicationIconFallback(" ")); + } + + @Test + public void explicitSenderUriRemainsAuthoritative() { + assertFalse(MyMIPushNotificationHelper + .shouldUseApplicationIconFallback("https://q.qlogo.cn/g?b=qq&nk=123")); + assertFalse(MyMIPushNotificationHelper + .shouldUseApplicationIconFallback("content://com.example/avatar")); + } +} diff --git a/push/src/test/java/com/xiaomi/push/service/KeyedSerialDispatcherTest.java b/push/src/test/java/com/xiaomi/push/service/KeyedSerialDispatcherTest.java new file mode 100644 index 000000000..ea5cdd3cf --- /dev/null +++ b/push/src/test/java/com/xiaomi/push/service/KeyedSerialDispatcherTest.java @@ -0,0 +1,389 @@ +package com.xiaomi.push.service; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.FutureTask; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.TimeUnit; + +import org.junit.Test; + +/** JVM coverage for ordering, parallelism, failure isolation and cleanup. */ +public class KeyedSerialDispatcherTest { + + @Test + public void sameKeyRunsInSubmissionOrder() throws Exception { + ExecutorService backend = Executors.newFixedThreadPool(2); + try { + KeyedSerialDispatcher dispatcher = + new KeyedSerialDispatcher<>(backend, 4, null); + List events = Collections.synchronizedList(new ArrayList<>()); + CountDownLatch firstStarted = new CountDownLatch(1); + CountDownLatch releaseFirst = new CountDownLatch(1); + CountDownLatch secondFinished = new CountDownLatch(1); + + dispatcher.execute("same", () -> { + events.add(1); + firstStarted.countDown(); + await(releaseFirst); + }); + assertTrue(firstStarted.await(2, TimeUnit.SECONDS)); + + dispatcher.execute("same", () -> { + events.add(2); + secondFinished.countDown(); + }); + + assertFalse("second command must wait for the first", secondFinished.await( + 100, TimeUnit.MILLISECONDS)); + releaseFirst.countDown(); + assertTrue(secondFinished.await(2, TimeUnit.SECONDS)); + assertEquals(Arrays.asList(1, 2), events); + assertTrue("completed key state must be removed", + awaitIdle(dispatcher, 2, TimeUnit.SECONDS)); + } finally { + backend.shutdownNow(); + } + } + + @Test + public void differentKeysCanRunConcurrently() throws Exception { + ExecutorService backend = Executors.newFixedThreadPool(2); + try { + KeyedSerialDispatcher dispatcher = + new KeyedSerialDispatcher<>(backend, 4, null); + CountDownLatch started = new CountDownLatch(2); + CountDownLatch release = new CountDownLatch(1); + CountDownLatch finished = new CountDownLatch(2); + + dispatcher.execute("a", () -> { + started.countDown(); + await(release); + finished.countDown(); + }); + dispatcher.execute("b", () -> { + started.countDown(); + await(release); + finished.countDown(); + }); + + assertTrue("unrelated keys should use separate workers", + started.await(2, TimeUnit.SECONDS)); + release.countDown(); + assertTrue(finished.await(2, TimeUnit.SECONDS)); + } finally { + backend.shutdownNow(); + } + } + + @Test + public void commandFailureDoesNotStallTheKey() throws Exception { + ExecutorService backend = Executors.newSingleThreadExecutor(); + try { + List failures = Collections.synchronizedList(new ArrayList<>()); + KeyedSerialDispatcher dispatcher = + new KeyedSerialDispatcher<>(backend, 4, + (key, failure) -> failures.add(failure)); + CountDownLatch secondFinished = new CountDownLatch(1); + + dispatcher.execute("same", () -> { + throw new AssertionError("expected"); + }); + dispatcher.execute("same", secondFinished::countDown); + + assertTrue(secondFinished.await(2, TimeUnit.SECONDS)); + assertEquals(1, failures.size()); + assertTrue(failures.get(0) instanceof AssertionError); + } finally { + backend.shutdownNow(); + } + } + + @Test + public void rejectingDelegateRunsCommandInlineAndCleansState() { + KeyedSerialDispatcher dispatcher = new KeyedSerialDispatcher<>( + command -> { + throw new RejectedExecutionException("closed"); + }, + 1, + null); + List events = new ArrayList<>(); + + dispatcher.execute("same", () -> events.add(1)); + dispatcher.execute("same", () -> events.add(2)); + + assertEquals(Arrays.asList(1, 2), events); + assertEquals(0, dispatcher.activeKeyCountForTest()); + assertEquals(0, dispatcher.queuedCommandCountForTest()); + } + + @Test + public void boundedQueuedPermitAppliesBackpressureAndEventuallyReleases() throws Exception { + ExecutorService backend = Executors.newSingleThreadExecutor(); + CountDownLatch firstStarted = new CountDownLatch(1); + CountDownLatch releaseFirst = new CountDownLatch(1); + try { + KeyedSerialDispatcher dispatcher = + new KeyedSerialDispatcher<>(backend, 1, null); + dispatcher.execute("same", () -> { + firstStarted.countDown(); + await(releaseFirst); + }); + assertTrue(firstStarted.await(2, TimeUnit.SECONDS)); + + // Active work is intentionally not counted against the bound. The + // second command occupies the one queued slot, so only the third + // producer has to apply back-pressure. + dispatcher.execute("same", () -> { }); + + FutureTask third = new FutureTask<>(() -> { + dispatcher.execute("same", () -> { }); + return null; + }); + Thread producer = new Thread(third, "keyed-dispatch-test-producer"); + producer.start(); + // With one queued-command slot, the producer must wait instead of + // growing an unbounded per-key queue. + Thread.sleep(50L); + assertFalse(third.isDone()); + + releaseFirst.countDown(); + third.get(2, TimeUnit.SECONDS); + producer.join(2_000L); + assertTrue("permit and key state must be released", + awaitIdle(dispatcher, 2, TimeUnit.SECONDS)); + assertEquals(1, dispatcher.availableSlotsForTest()); + } finally { + releaseFirst.countDown(); + backend.shutdownNow(); + } + } + + @Test + public void workerReentryNeverWaitsWhenQueuedSlotsAreFull() throws Exception { + ExecutorService backend = Executors.newSingleThreadExecutor(); + CountDownLatch firstStarted = new CountDownLatch(1); + CountDownLatch allowReentry = new CountDownLatch(1); + CountDownLatch reentryReturned = new CountDownLatch(1); + CountDownLatch releaseFirst = new CountDownLatch(1); + List events = Collections.synchronizedList(new ArrayList<>()); + try { + KeyedSerialDispatcher dispatcher = + new KeyedSerialDispatcher<>(backend, 1, null); + dispatcher.execute("same", () -> { + events.add(1); + firstStarted.countDown(); + await(allowReentry); + // The second command fills the only queue slot. This call is + // made from a dispatcher worker and must use the emergency + // no-slot path instead of waiting for itself. + dispatcher.execute("same", () -> events.add(3)); + reentryReturned.countDown(); + await(releaseFirst); + }); + assertTrue(firstStarted.await(2, TimeUnit.SECONDS)); + dispatcher.execute("same", () -> events.add(2)); + allowReentry.countDown(); + assertTrue(reentryReturned.await(2, TimeUnit.SECONDS)); + releaseFirst.countDown(); + + assertTrue(awaitIdle(dispatcher, 2, TimeUnit.SECONDS)); + assertEquals(Arrays.asList(1, 2, 3), events); + assertEquals(1, dispatcher.availableSlotsForTest()); + } finally { + allowReentry.countDown(); + releaseFirst.countDown(); + backend.shutdownNow(); + } + } + + @Test + public void ordinaryProducerRunsIdleKeyInlineWhenQueuedSlotsAreFull() { + List events = new ArrayList<>(); + List scheduled = new ArrayList<>(); + KeyedSerialDispatcher dispatcher = new KeyedSerialDispatcher<>( + scheduled::add, 1, null); + + dispatcher.execute("queued", () -> events.add(1)); + // The only bounded slot is occupied by the queued command. A new, + // unrelated idle key should use the lossless caller-runs path instead + // of waiting for the first delegate task to execute. + dispatcher.execute("inline", () -> events.add(2)); + + assertEquals(Arrays.asList(2), events); + assertEquals(1, scheduled.size()); + assertEquals(1, dispatcher.activeKeyCountForTest()); + + scheduled.get(0).run(); + assertEquals(Arrays.asList(2, 1), events); + assertEquals(1, dispatcher.availableSlotsForTest()); + } + + @Test + public void inlineKeyRegistrationPreservesOrderForConcurrentSubmission() + throws Exception { + List scheduled = Collections.synchronizedList(new ArrayList<>()); + List events = Collections.synchronizedList(new ArrayList<>()); + KeyedSerialDispatcher dispatcher = new KeyedSerialDispatcher<>( + scheduled::add, 1, null); + CountDownLatch firstStarted = new CountDownLatch(1); + CountDownLatch releaseFirst = new CountDownLatch(1); + CountDownLatch secondFinished = new CountDownLatch(1); + FutureTask first = new FutureTask<>(() -> { + dispatcher.execute("same", () -> { + events.add(1); + firstStarted.countDown(); + await(releaseFirst); + }); + return null; + }); + Thread firstProducer = new Thread(first, "keyed-dispatch-inline-first"); + + // Occupy the only queued slot so the first "same" submission must use + // the idle-key CallerRuns path. + dispatcher.execute("blocker", () -> events.add(0)); + firstProducer.start(); + assertTrue(firstStarted.await(2, TimeUnit.SECONDS)); + + FutureTask second = new FutureTask<>(() -> { + dispatcher.execute("same", () -> { + events.add(2); + secondFinished.countDown(); + }); + return null; + }); + Thread secondProducer = new Thread(second, "keyed-dispatch-inline-second"); + secondProducer.start(); + + assertFalse("same-key submission must wait behind inline work", + secondFinished.await(100, TimeUnit.MILLISECONDS)); + // The unrelated queued command releases the only permit; the waiting + // same-key producer can then append behind the inline command. + scheduled.get(0).run(); + releaseFirst.countDown(); + second.get(2, TimeUnit.SECONDS); + first.get(2, TimeUnit.SECONDS); + secondProducer.join(2_000L); + + assertEquals(Arrays.asList(1, 0, 2), events); + assertEquals(1, dispatcher.availableSlotsForTest()); + } + + @Test + public void delegateRuntimeExceptionFallsBackInlineAndCleansState() { + List failures = Collections.synchronizedList(new ArrayList<>()); + List events = new ArrayList<>(); + KeyedSerialDispatcher dispatcher = new KeyedSerialDispatcher<>( + command -> { + throw new IllegalStateException("executor failed"); + }, + 2, + (key, failure) -> failures.add(failure)); + + dispatcher.execute("same", () -> { + events.add(1); + dispatcher.execute("same", () -> events.add(2)); + }); + + assertEquals(Arrays.asList(1, 2), events); + assertEquals(1, failures.size()); + assertTrue(failures.get(0) instanceof IllegalStateException); + assertEquals(0, dispatcher.activeKeyCountForTest()); + assertEquals(0, dispatcher.queuedCommandCountForTest()); + assertEquals(2, dispatcher.availableSlotsForTest()); + } + + @Test + public void delegateErrorFallsBackInlineAndCleansState() { + List failures = Collections.synchronizedList(new ArrayList<>()); + List events = new ArrayList<>(); + KeyedSerialDispatcher dispatcher = new KeyedSerialDispatcher<>( + command -> { + throw new AssertionError("executor failed"); + }, + 1, + (key, failure) -> failures.add(failure)); + + dispatcher.execute("same", () -> events.add(1)); + + assertEquals(Arrays.asList(1), events); + assertEquals(1, failures.size()); + assertTrue(failures.get(0) instanceof AssertionError); + assertEquals(0, dispatcher.activeKeyCountForTest()); + assertEquals(0, dispatcher.queuedCommandCountForTest()); + assertEquals(1, dispatcher.availableSlotsForTest()); + } + + @Test + public void shutdownNowReturnsPendingCommandsReleasesPermitsAndRejectsNewWork() { + List scheduled = Collections.synchronizedList(new ArrayList<>()); + List events = new ArrayList<>(); + KeyedSerialDispatcher dispatcher = new KeyedSerialDispatcher<>( + scheduled::add, + 2, + null); + + dispatcher.execute("same", () -> events.add(1)); + dispatcher.execute("same", () -> events.add(2)); + assertEquals(2, dispatcher.queuedCommandCountForTest()); + assertEquals(0, dispatcher.availableSlotsForTest()); + + List pending = dispatcher.shutdownNow(); + + assertEquals(2, pending.size()); + assertEquals(0, events.size()); + assertEquals(0, dispatcher.activeKeyCountForTest()); + assertEquals(0, dispatcher.queuedCommandCountForTest()); + assertEquals(2, dispatcher.availableSlotsForTest()); + try { + dispatcher.execute("same", () -> events.add(3)); + throw new AssertionError("closed dispatcher accepted a command"); + } catch (RejectedExecutionException expected) { + // Expected lifecycle contract. + } + + // A drain wrapper which was already handed to the delegate becomes a + // no-op after shutdownNow; it cannot resurrect detached commands. + assertEquals(1, scheduled.size()); + scheduled.get(0).run(); + assertEquals(0, dispatcher.activeKeyCountForTest()); + } + + private static void await(CountDownLatch latch) { + boolean interrupted = false; + while (true) { + try { + latch.await(); + break; + } catch (InterruptedException ignored) { + interrupted = true; + } + } + if (interrupted) { + Thread.currentThread().interrupt(); + } + } + + private static boolean awaitIdle( + KeyedSerialDispatcher dispatcher, long timeout, TimeUnit unit) { + long deadline = System.nanoTime() + unit.toNanos(timeout); + while (System.nanoTime() < deadline) { + if (dispatcher.activeKeyCountForTest() == 0 + && dispatcher.queuedCommandCountForTest() == 0) { + return true; + } + Thread.yield(); + } + return dispatcher.activeKeyCountForTest() == 0 + && dispatcher.queuedCommandCountForTest() == 0; + } +} diff --git a/push/src/test/java/com/xiaomi/push/service/MIPushEventProcessorAspectPolicyTest.java b/push/src/test/java/com/xiaomi/push/service/MIPushEventProcessorAspectPolicyTest.java new file mode 100644 index 000000000..06caecfd1 --- /dev/null +++ b/push/src/test/java/com/xiaomi/push/service/MIPushEventProcessorAspectPolicyTest.java @@ -0,0 +1,28 @@ +package com.xiaomi.push.service; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import com.elvishew.xlog.XLog; +import com.xiaomi.xmpush.thrift.ActionType; + +import org.junit.Before; +import org.junit.Test; + +public class MIPushEventProcessorAspectPolicyTest { + @Before + public void initializeLogging() { + XLog.init(); + } + + @Test + public void onlyRegistrationBypassesAppAlivePolicy() { + assertTrue(MIPushEventProcessorAspect.bypassesAppAliveCheck( + ActionType.Registration)); + assertFalse(MIPushEventProcessorAspect.bypassesAppAliveCheck( + ActionType.SendMessage)); + assertFalse(MIPushEventProcessorAspect.bypassesAppAliveCheck( + ActionType.Notification)); + assertFalse(MIPushEventProcessorAspect.bypassesAppAliveCheck(null)); + } +} diff --git a/push/src/test/java/com/xiaomi/push/service/MyNotificationIconHelperTest.java b/push/src/test/java/com/xiaomi/push/service/MyNotificationIconHelperTest.java new file mode 100644 index 000000000..be49bd13b --- /dev/null +++ b/push/src/test/java/com/xiaomi/push/service/MyNotificationIconHelperTest.java @@ -0,0 +1,28 @@ +package com.xiaomi.push.service; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class MyNotificationIconHelperTest { + @Test + public void sampleSizeKeepsNormalIconsSharp() { + assertEquals(1, MyNotificationIconHelper.calculateSampleSize(48, 48, 48)); + assertEquals(2, MyNotificationIconHelper.calculateSampleSize(192, 96, 48)); + } + + @Test + public void sampleSizeBoundsPanoramicDecodeMemory() { + int sample = MyNotificationIconHelper.calculateSampleSize(20_000, 100, 48); + + assertTrue(sample >= 10); + assertTrue((20_000L / sample) * (100L / sample) <= 1024L * 1024L); + } + + @Test + public void invalidBoundsUseSafeDefault() { + assertEquals(1, MyNotificationIconHelper.calculateSampleSize(-1, 100, 48)); + assertEquals(1, MyNotificationIconHelper.calculateSampleSize(100, 100, 0)); + } +} diff --git a/push/src/test/java/com/xiaomi/push/service/NotificationClickPolicyTest.java b/push/src/test/java/com/xiaomi/push/service/NotificationClickPolicyTest.java new file mode 100644 index 000000000..98b7b2e6d --- /dev/null +++ b/push/src/test/java/com/xiaomi/push/service/NotificationClickPolicyTest.java @@ -0,0 +1,36 @@ +package com.xiaomi.push.service; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import com.elvishew.xlog.XLog; + +import org.junit.BeforeClass; +import org.junit.Test; + +/** Pure click-routing contracts that do not depend on Android framework mocks. */ +public class NotificationClickPolicyTest { + @BeforeClass + public static void initializeLogging() { + XLog.init(); + } + + @Test + public void onlyServiceContentIntentCarriesAuxiliaryWhitelistToken() { + assertTrue(MyMIPushNotificationHelper.shouldCarryTemporaryWhitelist(false)); + assertFalse(MyMIPushNotificationHelper.shouldCarryTemporaryWhitelist(true)); + } + + @Test + public void onlyPrivateOrReplayRoutesUseClickTrampoline() { + assertTrue(MyMIPushNotificationHelper.shouldUseClickTrampoline(false, false)); + assertTrue(MyMIPushNotificationHelper.shouldUseClickTrampoline(true, true)); + assertFalse(MyMIPushNotificationHelper.shouldUseClickTrampoline(false, true)); + } + + @Test + public void discoveredFocusRoutesRemainDirectAndBridgeExtraFree() { + assertFalse(MyMIPushNotificationHelper.shouldUseClickTrampoline(false, true)); + assertFalse(MyMIPushNotificationHelper.shouldAttachMiPushBridgeExtras(true)); + } +} diff --git a/push/src/test/java/com/xiaomi/push/service/NotificationDispatchPipelineTest.java b/push/src/test/java/com/xiaomi/push/service/NotificationDispatchPipelineTest.java new file mode 100644 index 000000000..868e0dea2 --- /dev/null +++ b/push/src/test/java/com/xiaomi/push/service/NotificationDispatchPipelineTest.java @@ -0,0 +1,136 @@ +package com.xiaomi.push.service; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import com.xiaomi.xmsf.push.utils.PackageConfig; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +public class NotificationDispatchPipelineTest { + + @Test + public void configurationFailureFallsBackToNotificationOnly() { + NotificationDispatchPipeline.DispatchPlan plan = + NotificationDispatchPipeline.planFromOperations(null); + + assertFalse(plan.wake); + assertTrue(plan.notify); + assertFalse(plan.open); + } + + @Test + public void ignoreStillSuppressesNotificationButPreservesOptionalStages() { + Set operations = new HashSet<>(); + operations.add(PackageConfig.OPERATION_WAKE); + operations.add(PackageConfig.OPERATION_IGNORE); + operations.add(PackageConfig.OPERATION_OPEN); + + NotificationDispatchPipeline.DispatchPlan plan = + NotificationDispatchPipeline.planFromOperations(operations); + + assertTrue(plan.wake); + assertFalse(plan.notify); + assertTrue(plan.open); + } + + @Test + public void wakeFailureDoesNotPreventNotificationOrOpen() { + NotificationDispatchPipeline.DispatchPlan plan = + NotificationDispatchPipeline.planFromOperations(withOperations( + PackageConfig.OPERATION_WAKE, PackageConfig.OPERATION_OPEN)); + List stages = new ArrayList<>(); + List failures = new ArrayList<>(); + + NotificationDispatchPipeline.dispatch( + plan, + () -> { + stages.add(NotificationDispatchPipeline.STAGE_WAKE); + throw new IllegalStateException("wake failed"); + }, + () -> stages.add(NotificationDispatchPipeline.STAGE_NOTIFY), + () -> stages.add(NotificationDispatchPipeline.STAGE_OPEN), + (stage, exception) -> failures.add(stage + ":" + exception.getMessage())); + + assertEquals(java.util.Arrays.asList("wake", "notify", "open"), stages); + assertEquals(java.util.Arrays.asList("wake:wake failed"), failures); + } + + @Test + public void notificationFailureDoesNotPreventOpen() { + NotificationDispatchPipeline.DispatchPlan plan = + NotificationDispatchPipeline.planFromOperations(withOperations( + PackageConfig.OPERATION_OPEN)); + List stages = new ArrayList<>(); + List failures = new ArrayList<>(); + + NotificationDispatchPipeline.dispatch( + plan, + () -> stages.add(NotificationDispatchPipeline.STAGE_WAKE), + () -> { + stages.add(NotificationDispatchPipeline.STAGE_NOTIFY); + throw new IllegalStateException("notify failed"); + }, + () -> stages.add(NotificationDispatchPipeline.STAGE_OPEN), + (stage, exception) -> failures.add(stage + ":" + exception.getMessage())); + + assertEquals(java.util.Arrays.asList("notify", "open"), stages); + assertEquals(java.util.Arrays.asList("notify:notify failed"), failures); + } + + @Test + public void openFailureIsIsolatedAfterNotificationSubmission() { + NotificationDispatchPipeline.DispatchPlan plan = + NotificationDispatchPipeline.planFromOperations(withOperations( + PackageConfig.OPERATION_OPEN)); + List stages = new ArrayList<>(); + List failures = new ArrayList<>(); + + NotificationDispatchPipeline.dispatch( + plan, + () -> stages.add(NotificationDispatchPipeline.STAGE_WAKE), + () -> stages.add(NotificationDispatchPipeline.STAGE_NOTIFY), + () -> { + stages.add(NotificationDispatchPipeline.STAGE_OPEN); + throw new IllegalStateException("open failed"); + }, + (stage, exception) -> failures.add(stage + ":" + exception.getMessage())); + + assertEquals(java.util.Arrays.asList("notify", "open"), stages); + assertEquals(java.util.Arrays.asList("open:open failed"), failures); + } + + @Test + public void failureReporterCannotBreakLaterStages() { + NotificationDispatchPipeline.DispatchPlan plan = + NotificationDispatchPipeline.planFromOperations(withOperations( + PackageConfig.OPERATION_WAKE, PackageConfig.OPERATION_OPEN)); + List stages = new ArrayList<>(); + + NotificationDispatchPipeline.dispatch( + plan, + () -> { + stages.add(NotificationDispatchPipeline.STAGE_WAKE); + throw new IllegalStateException("wake failed"); + }, + () -> stages.add(NotificationDispatchPipeline.STAGE_NOTIFY), + () -> stages.add(NotificationDispatchPipeline.STAGE_OPEN), + (stage, exception) -> { + throw new IllegalStateException("logger failed"); + }); + + assertEquals(java.util.Arrays.asList("wake", "notify", "open"), stages); + } + + private static Set withOperations(String... values) { + Set operations = new HashSet<>(); + java.util.Collections.addAll(operations, values); + return operations; + } +} diff --git a/push/src/test/java/com/xiaomi/push/service/NotificationExecutorTest.java b/push/src/test/java/com/xiaomi/push/service/NotificationExecutorTest.java new file mode 100644 index 000000000..5651f77d7 --- /dev/null +++ b/push/src/test/java/com/xiaomi/push/service/NotificationExecutorTest.java @@ -0,0 +1,322 @@ +package com.xiaomi.push.service; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import com.elvishew.xlog.XLog; +import com.xiaomi.xmpush.thrift.PushMetaInfo; +import com.xiaomi.xmpush.thrift.XmPushActionContainer; + +import android.content.pm.ActivityInfo; +import android.content.Intent; +import android.content.pm.ResolveInfo; + +import org.junit.Before; +import org.junit.Test; + +import java.util.HashMap; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; + +public class NotificationExecutorTest { + + @Before + public void setUp() { + XLog.init(); + } + + @Test + public void testNotificationExecutorConfiguration() { + ThreadPoolExecutor executor = MyMIPushNotificationHelper.getNotificationExecutor(); + assertNotNull("Notification executor must not be null", executor); + + assertEquals("Core pool size must be 3", 3, executor.getCorePoolSize()); + assertEquals("Maximum pool size must be 3", 3, executor.getMaximumPoolSize()); + assertEquals("Keep alive time must be 30 seconds", 30L, executor.getKeepAliveTime(TimeUnit.SECONDS)); + assertTrue("Core thread timeout must be enabled", executor.allowsCoreThreadTimeOut()); + + assertTrue("Work queue must be ArrayBlockingQueue", executor.getQueue() instanceof ArrayBlockingQueue); + assertEquals("Queue remaining + size initial capacity must match the bounded payload queue", + MyMIPushNotificationHelper.NOTIFICATION_QUEUE_CAPACITY, + executor.getQueue().remainingCapacity() + executor.getQueue().size()); + + assertTrue("RejectedExecutionHandler must be CallerRunsPolicy", + executor.getRejectedExecutionHandler() instanceof ThreadPoolExecutor.CallerRunsPolicy); + } + + @Test + public void testThreadFactoryNaming() { + ThreadPoolExecutor executor = MyMIPushNotificationHelper.getNotificationExecutor(); + Thread thread = executor.getThreadFactory().newThread(() -> {}); + assertNotNull(thread); + assertTrue("Thread name must start with mipush-notification-", + thread.getName().startsWith("mipush-notification-")); + } + + @Test + public void notificationDispatchKeyUsesTheFullPublishedIdentity() { + MyMIPushNotificationHelper.NotificationKey key = + new MyMIPushNotificationHelper.NotificationKey( + "com.example.client", "mipush_com.example.client", 42); + MyMIPushNotificationHelper.NotificationKey same = + new MyMIPushNotificationHelper.NotificationKey( + "com.example.client", "mipush_com.example.client", 42); + + assertEquals(key, same); + assertEquals(key.hashCode(), same.hashCode()); + assertTrue(!key.equals(new MyMIPushNotificationHelper.NotificationKey( + "com.example.other", "mipush_com.example.client", 42))); + assertTrue(!key.equals(new MyMIPushNotificationHelper.NotificationKey( + "com.example.client", "other-tag", 42))); + assertTrue(!key.equals(new MyMIPushNotificationHelper.NotificationKey( + "com.example.client", "mipush_com.example.client", 43))); + } + + @Test + public void notificationTargetPackageUsesMiuiWrapperTarget() { + XmPushActionContainer container = new XmPushActionContainer(); + container.packageName = "com.xiaomi.xmsf"; + PushMetaInfo metaInfo = new PushMetaInfo(); + metaInfo.extra = new HashMap<>(); + metaInfo.extra.put("miui_package_name", "com.example.client"); + container.metaInfo = metaInfo; + + assertEquals("com.example.client", + MyMIPushNotificationHelper.getNotificationTargetPackage(container)); + } + + @Test + public void notificationTargetPackageFallsBackToContainerPackage() { + XmPushActionContainer container = new XmPushActionContainer(); + container.packageName = "com.example.client"; + + assertEquals("com.example.client", + MyMIPushNotificationHelper.getNotificationTargetPackage(container)); + assertEquals("", MyMIPushNotificationHelper.getNotificationTargetPackage(null)); + } + + @Test + public void styleActionsUseOfficialXiaomiKeys() { + assertEquals("notification_style_button_left_notify_effect", + MyMIPushNotificationHelper.styleActionKeys(1).notifyEffect); + assertEquals("notification_style_button_mid_notify_effect", + MyMIPushNotificationHelper.styleActionKeys(2).notifyEffect); + assertEquals("notification_style_button_right_notify_effect", + MyMIPushNotificationHelper.styleActionKeys(3).notifyEffect); + + MyMIPushNotificationHelper.StyleActionKeys keys = + MyMIPushNotificationHelper.styleActionKeys(4); + + assertEquals("notification_colorful_button_notify_effect", keys.notifyEffect); + assertEquals("notification_colorful_button_intent_uri", keys.intentUri); + assertEquals("notification_colorful_button_intent_class", keys.intentClass); + assertEquals("notification_colorful_button_web_uri", keys.webUri); + } + + @Test + public void resolvedActivityMustBelongToTargetPackage() { + ResolveInfo resolved = new ResolveInfo(); + resolved.activityInfo = new ActivityInfo(); + resolved.activityInfo.packageName = "com.example.target"; + + assertTrue(MyMIPushNotificationHelper.isResolvedActivityInTargetPackage( + "com.example.target", resolved)); + assertTrue(!MyMIPushNotificationHelper.isResolvedActivityInTargetPackage( + "com.example.other", resolved)); + assertTrue(!MyMIPushNotificationHelper.isResolvedActivityInTargetPackage( + "com.example.target", null)); + + ResolveInfo withoutActivity = new ResolveInfo(); + assertTrue(!MyMIPushNotificationHelper.isResolvedActivityInTargetPackage( + "com.example.target", withoutActivity)); + assertTrue(!MyMIPushNotificationHelper.isResolvedActivityInTargetPackage( + null, resolved)); + assertTrue(!MyMIPushNotificationHelper.isResolvedActivityInTargetPackage( + "", resolved)); + } + + @Test + public void clickedActivitySettingUsesThreeStateContract() { + Intent activity = new Intent(); + + // Explicit values take precedence over the style-derived default. + assertTrue(MyMIPushNotificationHelper.shouldUseActivityClick( + Boolean.TRUE, false, activity, false)); + assertTrue(!MyMIPushNotificationHelper.shouldUseActivityClick( + Boolean.FALSE, true, activity, false)); + + // A historical replay with a sender route must never fall back to the + // stale Service click path, even under an old compatibility opt-out. + assertTrue(MyMIPushNotificationHelper.shouldUseActivityClick( + Boolean.FALSE, false, activity, true)); + + // An absent setting uses the direct Activity path for both ordinary and + // MessagingStyle notifications. This avoids Android 16's background + // service-to-Activity launch restriction after a notification click. + assertTrue(MyMIPushNotificationHelper.shouldUseActivityClick( + null, true, activity, false)); + assertTrue(MyMIPushNotificationHelper.shouldUseActivityClick( + null, false, activity, false)); + + // No resolved target Activity must always use the safe service path, + // even when the setting or style asks for an Activity. + assertTrue(!MyMIPushNotificationHelper.shouldUseActivityClick( + Boolean.TRUE, true, null, false)); + assertTrue(!MyMIPushNotificationHelper.shouldUseActivityClick( + null, true, null, true)); + } + + @Test + public void explicitSdkRouteWinsOverPayloadRoute() { + Intent officialBridge = new Intent("official-bridge"); + Intent payloadDeepLink = new Intent("payload-deep-link"); + + // Sender proxy Activities must keep the official intent_uri; a + // similarly resolvable URI embedded in the payload must not bypass + // that bridge. + assertSame(officialBridge, MyMIPushNotificationHelper.chooseClickRoute( + officialBridge, payloadDeepLink)); + + // Apps that omit notify_effect/intent_uri still get the encrypted + // payload deep link. + assertSame(payloadDeepLink, MyMIPushNotificationHelper.chooseClickRoute( + null, payloadDeepLink)); + assertSame(null, MyMIPushNotificationHelper.chooseClickRoute(null, null)); + } + + @Test + public void focusRouteIsPreferredBeforeEncryptedPayloadFallback() { + Intent focusDeepLink = new Intent("focus-deep-link"); + Intent payloadDeepLink = new Intent("payload-deep-link"); + Intent officialBridge = new Intent("official-bridge"); + + assertTrue(focusDeepLink == MyMIPushNotificationHelper.chooseClickRoute( + null, focusDeepLink, payloadDeepLink)); + assertTrue(officialBridge == MyMIPushNotificationHelper.chooseClickRoute( + officialBridge, focusDeepLink, payloadDeepLink)); + assertTrue(payloadDeepLink == MyMIPushNotificationHelper.chooseClickRoute( + null, null, payloadDeepLink)); + } + + @Test + public void discoveredDeepLinksDoNotReceiveMiPushBridgeExtras() { + assertTrue(MyMIPushNotificationHelper.shouldAttachMiPushBridgeExtras(false)); + assertTrue(!MyMIPushNotificationHelper.shouldAttachMiPushBridgeExtras(true)); + } + + @Test + public void onlyReplaySenderRouteUsesSdkFirstTrampoline() { + assertTrue(MyMIPushNotificationHelper.shouldUseReplayClickTrampoline( + true, true, false)); + assertTrue(!MyMIPushNotificationHelper.shouldUseReplayClickTrampoline( + false, true, false)); + assertTrue(!MyMIPushNotificationHelper.shouldUseReplayClickTrampoline( + true, false, false)); + assertTrue(!MyMIPushNotificationHelper.shouldUseReplayClickTrampoline( + true, true, true)); + } + + @Test + public void configuredClickRewritePrefersSenderContractByDefault() { + PushMetaInfo sender = clickMeta( + "2", + "intent:#Intent;action=com.example.third.push;S.message=42;end"); + PushMetaInfo configured = clickMeta( + "2", + "example://conversation/42"); + + assertTrue(MyMIPushNotificationHelper.shouldPreferSenderClickContract( + sender, configured)); + } + + @Test + public void unchangedClickContractIgnoresPresentationRewrites() { + PushMetaInfo sender = clickMeta("2", "example://conversation/42"); + PushMetaInfo configured = clickMeta("2", "example://conversation/42"); + configured.extra.put("channel_name", "Direct messages"); + configured.extra.put("__mi_push_use_messaging_style", "true"); + + assertTrue(!MyMIPushNotificationHelper.shouldPreferSenderClickContract( + sender, configured)); + } + + @Test + public void configuredClickRewriteCanBeExplicitlyAllowed() { + PushMetaInfo sender = clickMeta( + "2", + "intent:#Intent;action=com.example.third.push;S.message=42;end"); + PushMetaInfo configured = clickMeta("2", "example://conversation/42"); + configured.extra.put( + MyMIPushNotificationHelper.ALLOW_CLICK_ROUTE_REWRITE, " TRUE "); + + assertTrue(!MyMIPushNotificationHelper.shouldPreferSenderClickContract( + sender, configured)); + + for (String value : new String[]{"false", "1", ""}) { + configured.extra.put( + MyMIPushNotificationHelper.ALLOW_CLICK_ROUTE_REWRITE, value); + assertTrue(value, MyMIPushNotificationHelper + .shouldPreferSenderClickContract(sender, configured)); + } + } + + @Test + public void everyPrimaryClickFieldParticipatesInRewriteDetection() { + String[] keys = { + "notify_effect", "intent_uri", "class_name", + "web_uri", "intent_flag" + }; + for (String key : keys) { + PushMetaInfo sender = new PushMetaInfo(); + sender.extra = new HashMap<>(); + PushMetaInfo configured = new PushMetaInfo(); + configured.extra = new HashMap<>(); + sender.extra.put(key, "sender-value"); + configured.extra.put(key, "configured-value"); + + assertTrue(key, MyMIPushNotificationHelper + .shouldPreferSenderClickContract(sender, configured)); + } + } + + @Test + public void senderClickContractComparisonIsNullSafe() { + PushMetaInfo sender = new PushMetaInfo(); + PushMetaInfo configured = new PushMetaInfo(); + + assertTrue(!MyMIPushNotificationHelper.shouldPreferSenderClickContract( + sender, configured)); + sender.url = "https://example.test/detail/42"; + assertTrue(MyMIPushNotificationHelper.shouldPreferSenderClickContract( + sender, configured)); + assertTrue(!MyMIPushNotificationHelper.shouldPreferSenderClickContract( + null, configured)); + } + + @Test + public void clickRouteOriginUsesSelectedIntentIdentity() { + Intent officialBridge = new Intent("official-bridge"); + Intent focusDeepLink = new Intent("focus-deep-link"); + Intent payloadDeepLink = new Intent("payload-deep-link"); + + assertTrue(!MyMIPushNotificationHelper.isDiscoveredClickRoute( + officialBridge, focusDeepLink, payloadDeepLink)); + assertTrue(MyMIPushNotificationHelper.isDiscoveredClickRoute( + focusDeepLink, focusDeepLink, payloadDeepLink)); + assertTrue(MyMIPushNotificationHelper.isDiscoveredClickRoute( + payloadDeepLink, focusDeepLink, payloadDeepLink)); + assertTrue(!MyMIPushNotificationHelper.isDiscoveredClickRoute( + null, focusDeepLink, payloadDeepLink)); + } + + private static PushMetaInfo clickMeta(String notifyEffect, String intentUri) { + PushMetaInfo metaInfo = new PushMetaInfo(); + metaInfo.extra = new HashMap<>(); + metaInfo.extra.put("notify_effect", notifyEffect); + metaInfo.extra.put("intent_uri", intentUri); + return metaInfo; + } +} diff --git a/push/src/test/java/com/xiaomi/push/service/WakeScreenThrottleTest.java b/push/src/test/java/com/xiaomi/push/service/WakeScreenThrottleTest.java new file mode 100644 index 000000000..a8d8bcda2 --- /dev/null +++ b/push/src/test/java/com/xiaomi/push/service/WakeScreenThrottleTest.java @@ -0,0 +1,98 @@ +package com.xiaomi.push.service; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicInteger; + +public class WakeScreenThrottleTest { + + @Test + public void firstRequestIsAllowedAndBurstIsSuppressed() { + AtomicLong now = new AtomicLong(10_000L); + WakeScreenThrottle throttle = new WakeScreenThrottle(now::get); + + assertTrue(throttle.tryAcquire("com.example.chat")); + now.addAndGet(WakeScreenThrottle.DEFAULT_MIN_INTERVAL_MILLIS - 1L); + assertFalse(throttle.tryAcquire("com.example.chat")); + now.incrementAndGet(); + assertTrue(throttle.tryAcquire("com.example.chat")); + } + + @Test + public void packagesHaveIndependentIntervals() { + AtomicLong now = new AtomicLong(1_000L); + WakeScreenThrottle throttle = new WakeScreenThrottle(now::get); + + assertTrue(throttle.tryAcquire("com.example.one")); + assertTrue(throttle.tryAcquire("com.example.two")); + now.addAndGet(1_000L); + assertFalse(throttle.tryAcquire("com.example.one")); + assertFalse(throttle.tryAcquire("com.example.two")); + } + + @Test + public void suppressedWakeDoesNotSuppressNotificationDispatch() { + AtomicLong now = new AtomicLong(1_000L); + AtomicInteger wakeLocks = new AtomicInteger(); + AtomicInteger notifications = new AtomicInteger(); + WakeScreenThrottle throttle = new WakeScreenThrottle(now::get); + NotificationDispatchPipeline.DispatchPlan plan = + new NotificationDispatchPipeline.DispatchPlan(true, true, false); + + for (int attempt = 0; attempt < 2; attempt++) { + NotificationDispatchPipeline.dispatch( + plan, + () -> { + if (throttle.tryAcquire("com.example.chat")) { + wakeLocks.incrementAndGet(); + } + }, + notifications::incrementAndGet, + null, + null); + } + + assertEquals(1, wakeLocks.get()); + assertEquals(2, notifications.get()); + } + + @Test + public void clockRollbackStartsANewEpoch() { + AtomicLong now = new AtomicLong(90_000L); + WakeScreenThrottle throttle = new WakeScreenThrottle(now::get); + + assertTrue(throttle.tryAcquire("com.example.chat")); + now.set(100L); + assertTrue(throttle.tryAcquire("com.example.chat")); + now.set(100L + WakeScreenThrottle.DEFAULT_MIN_INTERVAL_MILLIS - 1L); + assertFalse(throttle.tryAcquire("com.example.chat")); + } + + @Test + public void cacheRemainsBoundedAndEvictsLeastRecentlyUsedPackage() { + AtomicLong now = new AtomicLong(1L); + WakeScreenThrottle throttle = new WakeScreenThrottle( + now::get, WakeScreenThrottle.DEFAULT_MIN_INTERVAL_MILLIS, 2); + + assertTrue(throttle.tryAcquire("com.example.one")); + now.incrementAndGet(); + assertTrue(throttle.tryAcquire("com.example.two")); + assertEquals(2, throttle.entryCountForTest()); + + // Touch one so the second package is the eldest entry. + now.addAndGet(WakeScreenThrottle.DEFAULT_MIN_INTERVAL_MILLIS); + assertTrue(throttle.tryAcquire("com.example.one")); + now.incrementAndGet(); + assertTrue(throttle.tryAcquire("com.example.three")); + assertEquals(2, throttle.entryCountForTest()); + + // The evicted package is treated as a first request again. + assertTrue(throttle.tryAcquire("com.example.two")); + assertEquals(2, throttle.entryCountForTest()); + } +} diff --git a/push/src/test/java/com/xiaomi/push/service/timers/AlarmManagerTimerSmokeTest.java b/push/src/test/java/com/xiaomi/push/service/timers/AlarmManagerTimerSmokeTest.java new file mode 100644 index 000000000..5e88876e4 --- /dev/null +++ b/push/src/test/java/com/xiaomi/push/service/timers/AlarmManagerTimerSmokeTest.java @@ -0,0 +1,53 @@ +package com.xiaomi.push.service.timers; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + +import android.app.PendingIntent; +import android.os.Build; + +import org.junit.Test; + +import top.trumeet.common.utils.AlarmSchedulePolicy; +import top.trumeet.common.utils.AlarmSchedulePolicy.AlarmScheduleType; + +public class AlarmManagerTimerSmokeTest { + + private static class DummyTimer { + private PendingIntent mPi; + private volatile long mNextPingTs; + } + + @Test + public void testTimerReflectiveWriteBack() { + DummyTimer timer = new DummyTimer(); + assertNull(timer.mPi); + assertEquals(0L, timer.mNextPingTs); + + long testTs = 123456789L; + AlarmManagerTimerAspect.setField(timer, "mNextPingTs", testTs); + assertEquals("mNextPingTs should be updated via reflection", testTs, timer.mNextPingTs); + } + + @Test + public void testPolicyExactBranch() { + AlarmScheduleType exactType = + AlarmSchedulePolicy.determineScheduleType(Build.VERSION_CODES.S, true); + assertEquals("Exact alarm allowed on API 31+ with permission", + AlarmScheduleType.EXACT, exactType); + + AlarmScheduleType preSType = + AlarmSchedulePolicy.determineScheduleType(Build.VERSION_CODES.R, false); + assertEquals("Exact alarm allowed on API < 31", + AlarmScheduleType.EXACT, preSType); + } + + @Test + public void testPolicyInexactBranch() { + AlarmScheduleType inexactType = + AlarmSchedulePolicy.determineScheduleType(Build.VERSION_CODES.S, false); + assertEquals("Inexact alarm scheduled on API 31+ without permission", + AlarmScheduleType.INEXACT_ALLOW_WHILE_IDLE, inexactType); + } +} diff --git a/push/src/test/java/com/xiaomi/xmsf/ManifestComponentContractTest.java b/push/src/test/java/com/xiaomi/xmsf/ManifestComponentContractTest.java new file mode 100644 index 000000000..f48b06c65 --- /dev/null +++ b/push/src/test/java/com/xiaomi/xmsf/ManifestComponentContractTest.java @@ -0,0 +1,125 @@ +package com.xiaomi.xmsf; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NodeList; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +import javax.xml.parsers.DocumentBuilderFactory; + +public class ManifestComponentContractTest { + private static final String ANDROID_NAMESPACE = "http://schemas.android.com/apk/res/android"; + private static final String XMSF_PACKAGE = "com.xiaomi.xmsf"; + private static final String PUSH_SERVICE = "com.xiaomi.push.service.XMPushService"; + private static final String PUSH_RECEIVER = + "com.xiaomi.xmsf.push.service.receivers.MiuiPushMessageReceiver"; + private static final String PUSH_MESSAGE_HANDLER = + "com.xiaomi.mipush.sdk.PushMessageHandler"; + + @Test + public void sourceManifestDeclaresProductionComponents() throws Exception { + Path pushDirectory = findPushDirectory(); + Document main = parse(pushDirectory.resolve("src/main/AndroidManifest.xml")); + + assertAttribute(main, "service", PUSH_SERVICE, "exported", "true"); + assertAttribute(main, "receiver", PUSH_RECEIVER, "exported", "true"); + assertAttribute(main, "service", PUSH_MESSAGE_HANDLER, "enabled", "true"); + assertAttribute(main, "service", PUSH_MESSAGE_HANDLER, "exported", "true"); + } + + @Test + public void mergedManifestPreservesCurrentVariantContract() throws Exception { + Document merged = parse(findMergedManifest()); + + assertAttribute(merged, "service", PUSH_SERVICE, "exported", "true"); + assertAttribute(merged, "receiver", PUSH_RECEIVER, "exported", "true"); + assertAttribute(merged, "service", PUSH_MESSAGE_HANDLER, "enabled", "true"); + assertAttribute(merged, "service", PUSH_MESSAGE_HANDLER, "exported", "true"); + } + + private static Path findPushDirectory() { + Path current = Paths.get(System.getProperty("user.dir")).toAbsolutePath().normalize(); + for (int depth = 0; current != null && depth < 6; depth++, current = current.getParent()) { + Path direct = current.resolve("src/main/AndroidManifest.xml"); + if (Files.isRegularFile(direct)) { + return current; + } + Path nested = current.resolve("push/src/main/AndroidManifest.xml"); + if (Files.isRegularFile(nested)) { + return current.resolve("push"); + } + } + throw new AssertionError("Unable to locate push module from " + System.getProperty("user.dir")); + } + + private static Path findMergedManifest() { + Path pushDirectory = findPushDirectory(); + String buildType = BuildConfig.BUILD_TYPE; + String variant = BuildConfig.FLAVOR + Character.toUpperCase(buildType.charAt(0)) + + buildType.substring(1); + String[] outputDirectories = { + "merged_manifest", + "merged_manifests", + "packaged_manifests" + }; + for (String outputDirectory : outputDirectories) { + Path candidate = pushDirectory.resolve("build/intermediates") + .resolve(outputDirectory) + .resolve(variant) + .resolve("AndroidManifest.xml"); + if (Files.isRegularFile(candidate)) { + return candidate; + } + } + throw new AssertionError("Unable to locate merged manifest for variant " + variant); + } + + private static Document parse(Path manifest) throws Exception { + assertTrue("Manifest must exist: " + manifest, Files.isRegularFile(manifest)); + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setNamespaceAware(true); + return factory.newDocumentBuilder().parse(manifest.toFile()); + } + + private static void assertAttribute( + Document document, + String componentType, + String componentName, + String attribute, + String expectedValue) { + Element component = findComponent(document, componentType, componentName); + assertNotNull(componentType + " must be declared: " + componentName, component); + assertEquals( + componentType + " " + componentName + " android:" + attribute, + expectedValue, + component.getAttributeNS(ANDROID_NAMESPACE, attribute)); + } + + private static Element findComponent( + Document document, String componentType, String componentName) { + NodeList components = document.getElementsByTagName(componentType); + for (int index = 0; index < components.getLength(); index++) { + Element component = (Element) components.item(index); + String declaredName = component.getAttributeNS(ANDROID_NAMESPACE, "name"); + if (normalizeComponentName(declaredName).equals(componentName)) { + return component; + } + } + return null; + } + + private static String normalizeComponentName(String componentName) { + if (componentName.startsWith(".")) { + return XMSF_PACKAGE + componentName; + } + return componentName; + } +} diff --git a/push/src/test/java/com/xiaomi/xmsf/NormalVariantContractTest.java b/push/src/test/java/com/xiaomi/xmsf/NormalVariantContractTest.java new file mode 100644 index 000000000..341c02fe5 --- /dev/null +++ b/push/src/test/java/com/xiaomi/xmsf/NormalVariantContractTest.java @@ -0,0 +1,22 @@ +package com.xiaomi.xmsf; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +import org.junit.Test; + +public class NormalVariantContractTest { + + @Test + public void testNormalVariantContract() { + assertEquals("Normal package name must be com.xiaomi.xmsf", "com.xiaomi.xmsf", BuildConfig.APPLICATION_ID); + } + + @Test + public void testVersionCodeContract() { + // Normal variant version code contract (1003003001 or normal) + if (BuildConfig.VERSION_CODE > 0) { + assertNotNull(BuildConfig.VERSION_NAME); + } + } +} diff --git a/push/src/test/java/com/xiaomi/xmsf/push/control/PushControllerUtilsTest.java b/push/src/test/java/com/xiaomi/xmsf/push/control/PushControllerUtilsTest.java new file mode 100644 index 000000000..1d25a98b4 --- /dev/null +++ b/push/src/test/java/com/xiaomi/xmsf/push/control/PushControllerUtilsTest.java @@ -0,0 +1,81 @@ +package com.xiaomi.xmsf.push.control; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.IntentFilter; + +import com.elvishew.xlog.XLog; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +public class PushControllerUtilsTest { + @Before + public void setUp() { + XLog.init(); + } + + @After + public void tearDown() { + PushControllerUtils.unregisterLiveReceiver(); + PushControllerUtils.onPushServiceDestroyed(); + } + + @Test + public void screenWakeReceiverRegistrationIsIdempotent() { + Context context = mock(Context.class); + when(context.getApplicationContext()).thenReturn(context); + + PushControllerUtils.registerLiveReceiver(context); + PushControllerUtils.registerLiveReceiver(context); + + verify(context, times(1)).registerReceiver( + any(BroadcastReceiver.class), any(IntentFilter.class)); + } + + @Test + public void screenWakeReceiverUnregistrationIsIdempotent() { + Context context = mock(Context.class); + when(context.getApplicationContext()).thenReturn(context); + PushControllerUtils.registerLiveReceiver(context); + + PushControllerUtils.unregisterLiveReceiver(); + PushControllerUtils.unregisterLiveReceiver(); + + verify(context, times(1)).unregisterReceiver(any(BroadcastReceiver.class)); + } + + @Test + public void pushServiceLifecycleStateIsIdempotent() { + assertFalse(PushControllerUtils.isPushServiceRunning()); + PushControllerUtils.onPushServiceCreated(); + PushControllerUtils.onPushServiceCreated(); + assertTrue(PushControllerUtils.isPushServiceRunning()); + PushControllerUtils.onPushServiceDestroyed(); + PushControllerUtils.onPushServiceDestroyed(); + assertFalse(PushControllerUtils.isPushServiceRunning()); + } + + @Test + public void screenWakeReceiverUsesApplicationContext() { + Context owner = mock(Context.class); + Context applicationContext = mock(Context.class); + when(owner.getApplicationContext()).thenReturn(applicationContext); + + PushControllerUtils.registerLiveReceiver(owner); + PushControllerUtils.unregisterLiveReceiver(); + + verify(applicationContext).registerReceiver( + any(BroadcastReceiver.class), any(IntentFilter.class)); + verify(applicationContext).unregisterReceiver(any(BroadcastReceiver.class)); + } +} diff --git a/push/src/test/java/com/xiaomi/xmsf/push/control/PushServiceDispatcherConfigTest.java b/push/src/test/java/com/xiaomi/xmsf/push/control/PushServiceDispatcherConfigTest.java new file mode 100644 index 000000000..6567512a8 --- /dev/null +++ b/push/src/test/java/com/xiaomi/xmsf/push/control/PushServiceDispatcherConfigTest.java @@ -0,0 +1,33 @@ +package com.xiaomi.xmsf.push.control; + +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.content.Context; + +import com.elvishew.xlog.XLog; +import com.xiaomi.xmsf.utils.ConfigCenter; + +import org.junit.Before; +import org.junit.Test; + +public class PushServiceDispatcherConfigTest { + + @Before + public void setUp() { + XLog.init(); + } + + @Test + public void dispatcherUsesConfigCenterForegroundServiceContract() { + Context context = mock(Context.class); + ConfigCenter configCenter = mock(ConfigCenter.class); + when(configCenter.isStartForegroundService(context)).thenReturn(true); + + assertTrue(PushServiceDispatcher.isPersistentForegroundEnabled(context, configCenter)); + + verify(configCenter).isStartForegroundService(context); + } +} diff --git a/push/src/test/java/com/xiaomi/xmsf/push/control/PushServiceStartPolicyTest.java b/push/src/test/java/com/xiaomi/xmsf/push/control/PushServiceStartPolicyTest.java new file mode 100644 index 000000000..8db28a538 --- /dev/null +++ b/push/src/test/java/com/xiaomi/xmsf/push/control/PushServiceStartPolicyTest.java @@ -0,0 +1,63 @@ +package com.xiaomi.xmsf.push.control; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; + +public class PushServiceStartPolicyTest { + + @Test + public void masterDisabledAlwaysReturnsSkip() { + assertAction(PushServiceStartPolicy.Action.SKIP, + false, true, true, true, true); + assertAction(PushServiceStartPolicy.Action.SKIP, + false, false, false, false, true); + } + + @Test + public void serviceRunningWithMasterEnabledReturnsStartService() { + assertAction(PushServiceStartPolicy.Action.START_SERVICE, + true, true, false, false, false); + assertAction(PushServiceStartPolicy.Action.START_SERVICE, + true, true, false, true, true); + } + + @Test + public void userInitiatedWithMasterEnabledReturnsStartService() { + assertAction(PushServiceStartPolicy.Action.START_SERVICE, + true, false, true, false, false); + assertAction(PushServiceStartPolicy.Action.START_SERVICE, + true, false, true, true, true); + } + + @Test + public void backgroundStartUsesConfiguredModeWhenPlatformAllowed() { + assertAction(PushServiceStartPolicy.Action.START_FOREGROUND, + true, false, false, true, true); + assertAction(PushServiceStartPolicy.Action.START_SERVICE, + true, false, false, false, true); + } + + @Test + public void backgroundStartReturnsSkipWhenPlatformNotAllowed() { + assertAction(PushServiceStartPolicy.Action.SKIP, + true, false, false, true, false); + assertAction(PushServiceStartPolicy.Action.SKIP, + true, false, false, false, false); + } + + private static void assertAction( + PushServiceStartPolicy.Action expected, + boolean isMasterEnabled, + boolean isServiceRunning, + boolean isUserInitiated, + boolean isPersistentForegroundEnabled, + boolean isPlatformAllowed) { + assertEquals(expected, PushServiceStartPolicy.evaluate( + isMasterEnabled, + isServiceRunning, + isUserInitiated, + isPersistentForegroundEnabled, + isPlatformAllowed)); + } +} diff --git a/push/src/test/java/com/xiaomi/xmsf/push/control/RegistrationRetryCoordinatorTest.java b/push/src/test/java/com/xiaomi/xmsf/push/control/RegistrationRetryCoordinatorTest.java new file mode 100644 index 000000000..036198a32 --- /dev/null +++ b/push/src/test/java/com/xiaomi/xmsf/push/control/RegistrationRetryCoordinatorTest.java @@ -0,0 +1,200 @@ +package com.xiaomi.xmsf.push.control; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +public class RegistrationRetryCoordinatorTest { + @Test + public void keepsOnlyOnePendingRetry() { + FakeScheduler scheduler = new FakeScheduler(); + RegistrationRetryCoordinator coordinator = new RegistrationRetryCoordinator(scheduler); + coordinator.enable(); + + assertTrue(coordinator.schedule(1_000L, generation -> () -> { })); + assertFalse(coordinator.schedule(2_000L, generation -> () -> { })); + + assertEquals(1, scheduler.posted.size()); + assertEquals(1_000L, scheduler.delays.get(0).longValue()); + } + + @Test + public void acceptsRetryBeforeExplicitEnableForDefaultEnabledStartup() { + FakeScheduler scheduler = new FakeScheduler(); + RegistrationRetryCoordinator coordinator = new RegistrationRetryCoordinator(scheduler); + + assertTrue(coordinator.schedule(1_000L, generation -> () -> { })); + } + + @Test + public void disableAndEnableUpdateVisibleState() { + RegistrationRetryCoordinator coordinator = new RegistrationRetryCoordinator( + new FakeScheduler()); + + assertTrue(coordinator.isEnabled()); + coordinator.disable(); + assertFalse(coordinator.isEnabled()); + coordinator.enable(); + assertTrue(coordinator.isEnabled()); + } + + @Test + public void disableCancelsPendingRetry() { + FakeScheduler scheduler = new FakeScheduler(); + RegistrationRetryCoordinator coordinator = new RegistrationRetryCoordinator(scheduler); + coordinator.enable(); + coordinator.schedule(1_000L, generation -> () -> { }); + Runnable pending = scheduler.posted.get(0); + + coordinator.disable(); + + assertSame(pending, scheduler.removed); + assertFalse(coordinator.begin(pending, scheduler.generation)); + } + + @Test + public void staleRunningRetryCannotJoinLaterEnableGeneration() { + FakeScheduler scheduler = new FakeScheduler(); + RegistrationRetryCoordinator coordinator = new RegistrationRetryCoordinator(scheduler); + coordinator.enable(); + coordinator.schedule(1_000L, generation -> { + scheduler.generation = generation; + return () -> { }; + }); + Runnable running = scheduler.posted.get(0); + long oldGeneration = scheduler.generation; + assertTrue(coordinator.begin(running, oldGeneration)); + + coordinator.disable(); + coordinator.enable(); + + assertFalse(coordinator.runIfActive(oldGeneration, () -> { })); + assertFalse(coordinator.schedule(2_000L, oldGeneration, generation -> () -> { })); + } + + @Test + public void failedPostDoesNotPermanentlyBlockRetries() { + FakeScheduler scheduler = new FakeScheduler(); + scheduler.acceptPosts = false; + RegistrationRetryCoordinator coordinator = new RegistrationRetryCoordinator(scheduler); + coordinator.enable(); + + assertFalse(coordinator.schedule(1_000L, generation -> () -> { })); + assertSame(scheduler.posted.get(0), scheduler.removed); + scheduler.acceptPosts = true; + assertTrue(coordinator.schedule(2_000L, generation -> () -> { })); + } + + @Test + public void nullTaskDoesNotBlockLaterRetry() { + FakeScheduler scheduler = new FakeScheduler(); + RegistrationRetryCoordinator coordinator = new RegistrationRetryCoordinator(scheduler); + + assertFalse(coordinator.schedule(1_000L, generation -> null)); + assertTrue(coordinator.schedule(2_000L, generation -> () -> { })); + } + + @Test + public void disabledCoordinatorRejectsInitialAndRetrySideEffects() { + FakeScheduler scheduler = new FakeScheduler(); + RegistrationRetryCoordinator coordinator = new RegistrationRetryCoordinator(scheduler); + coordinator.schedule(1_000L, generation -> { + scheduler.generation = generation; + return () -> { }; + }); + long generation = scheduler.generation; + coordinator.disable(); + int[] calls = {0}; + + assertFalse(coordinator.runIfActive(generation, () -> calls[0]++)); + assertFalse(coordinator.runIfEnabled(() -> calls[0]++)); + assertEquals(0, calls[0]); + } + + @Test + public void activeGenerationRunsRegistrationSideEffectOnce() { + FakeScheduler scheduler = new FakeScheduler(); + RegistrationRetryCoordinator coordinator = new RegistrationRetryCoordinator(scheduler); + coordinator.schedule(1_000L, generation -> { + scheduler.generation = generation; + return () -> { }; + }); + int[] calls = {0}; + + assertTrue(coordinator.runIfActive(scheduler.generation, () -> calls[0]++)); + assertEquals(1, calls[0]); + } + + @Test + public void successCanCancelAnOlderPendingRetry() { + FakeScheduler scheduler = new FakeScheduler(); + RegistrationRetryCoordinator coordinator = new RegistrationRetryCoordinator(scheduler); + coordinator.schedule(1_000L, generation -> () -> { }); + Runnable pending = scheduler.posted.get(0); + + coordinator.cancelPending(); + + assertSame(pending, scheduler.removed); + assertFalse(coordinator.begin(pending, 1L)); + } + + @Test + public void disableWaitsForRunningRegistrationThenWinsOrdering() throws Exception { + RegistrationRetryCoordinator coordinator = new RegistrationRetryCoordinator( + new FakeScheduler()); + CountDownLatch actionStarted = new CountDownLatch(1); + CountDownLatch releaseAction = new CountDownLatch(1); + List order = new ArrayList<>(); + Thread registration = new Thread(() -> coordinator.runIfActive(1L, () -> { + actionStarted.countDown(); + try { + releaseAction.await(1, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + order.add("register"); + })); + Thread disable = new Thread(() -> { + coordinator.disable(); + order.add("disable"); + }); + + registration.start(); + assertTrue(actionStarted.await(1, TimeUnit.SECONDS)); + disable.start(); + releaseAction.countDown(); + registration.join(1_000L); + disable.join(1_000L); + + assertEquals(java.util.Arrays.asList("register", "disable"), order); + assertFalse(coordinator.isEnabled()); + } + + private static final class FakeScheduler implements RegistrationRetryCoordinator.Scheduler { + final List posted = new ArrayList<>(); + final List delays = new ArrayList<>(); + Runnable removed; + long generation; + boolean acceptPosts = true; + + @Override + public boolean postDelayed(Runnable task, long delayMs) { + posted.add(task); + delays.add(delayMs); + return acceptPosts; + } + + @Override + public void removeCallbacks(Runnable task) { + removed = task; + } + } +} diff --git a/push/src/test/java/com/xiaomi/xmsf/push/control/StartupWorkPolicyTest.java b/push/src/test/java/com/xiaomi/xmsf/push/control/StartupWorkPolicyTest.java new file mode 100644 index 000000000..62f36717c --- /dev/null +++ b/push/src/test/java/com/xiaomi/xmsf/push/control/StartupWorkPolicyTest.java @@ -0,0 +1,24 @@ +package com.xiaomi.xmsf.push.control; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public class StartupWorkPolicyTest { + @Test + public void disabledOrNonMainProcessesNeverRunAutomaticStartupWork() { + assertFalse(StartupWorkPolicy.shouldRunAppStartup(true, false)); + assertFalse(StartupWorkPolicy.shouldRunAppStartup(false, true)); + assertFalse(StartupWorkPolicy.shouldRunAppStartup(false, false)); + assertTrue(StartupWorkPolicy.shouldRunAppStartup(true, true)); + } + + @Test + public void elapsedThrottleHandlesFirstRunBoundaryAndReboot() { + assertTrue(StartupWorkPolicy.shouldRunThrottled(0L, 1L, 300_000L)); + assertFalse(StartupWorkPolicy.shouldRunThrottled(1_000L, 300_999L, 300_000L)); + assertTrue(StartupWorkPolicy.shouldRunThrottled(1_000L, 301_000L, 300_000L)); + assertTrue(StartupWorkPolicy.shouldRunThrottled(900_000L, 100L, 300_000L)); + } +} diff --git a/push/src/test/java/com/xiaomi/xmsf/push/notification/FocusNotificationReplayTest.java b/push/src/test/java/com/xiaomi/xmsf/push/notification/FocusNotificationReplayTest.java new file mode 100644 index 000000000..d5c6f9f2b --- /dev/null +++ b/push/src/test/java/com/xiaomi/xmsf/push/notification/FocusNotificationReplayTest.java @@ -0,0 +1,68 @@ +package com.xiaomi.xmsf.push.notification; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.util.HashMap; +import java.util.Map; + +public class FocusNotificationReplayTest { + @Test + public void refreshesTopLevelAndNestedSequenceWithoutMutatingInput() { + String original = "{\"title\":\"待支付\",\"sequence\":\"123\"," + + "\"param_v2\":{\"sequence\":123,\"business\":\"food_delivery\"," + + "\"filterWhenNoPermission\":true}}"; + Map input = new HashMap<>(); + input.put("miui.focus.param", original); + input.put("t_fe_s", "123"); + + Map refreshed = FocusNotificationReplay.refreshExtras(input, 456L); + + assertNotSame(input, refreshed); + assertEquals(original, input.get("miui.focus.param")); + assertTrue(refreshed.get("miui.focus.param").contains("\"sequence\":\"456\"")); + assertTrue(refreshed.get("miui.focus.param").contains("\"business\":\"food_delivery\"")); + assertTrue(refreshed.get("miui.focus.param").contains("\"filterWhenNoPermission\":false")); + assertEquals("456", refreshed.get("t_fe_s")); + } + + @Test + public void disablesRootPermissionFilterAndKeepsNumericNestedSequence() { + Map input = new HashMap<>(); + input.put("miui.focus.param", "{\"sequence\":123," + + "\"filterWhenNoPermission\":true,\"param_v2\":{\"sequence\":456}} "); + + String refreshed = FocusNotificationReplay.refreshExtras(input, 789L) + .get("miui.focus.param"); + + assertTrue(refreshed.contains("\"sequence\":789")); + assertTrue(refreshed.contains("\"filterWhenNoPermission\":false")); + assertTrue(refreshed.contains("\"param_v2\":{\"sequence\":789}")); + } + + @Test + public void keepsMalformedAndOversizedParametersSafe() { + Map malformed = new HashMap<>(); + malformed.put("miui.focus.param", "not-json"); + assertEquals("not-json", + FocusNotificationReplay.refreshExtras(malformed, 456L) + .get("miui.focus.param")); + + Map oversized = new HashMap<>(); + oversized.put("miui.focus.param", "{\"payload\":\"" + + "a".repeat(FocusNotificationSafety.MAX_PARAMETER_BYTES) + "\"}"); + assertEquals(oversized.get("miui.focus.param"), + FocusNotificationReplay.refreshExtras(oversized, 456L) + .get("miui.focus.param")); + } + + @Test + public void returnsEmptyOrNullMapAsIs() { + assertEquals(null, FocusNotificationReplay.refreshExtras(null, 456L)); + Map empty = new HashMap<>(); + assertEquals(empty, FocusNotificationReplay.refreshExtras(empty, 456L)); + } +} diff --git a/push/src/test/java/com/xiaomi/xmsf/push/notification/FocusNotificationSafetyTest.java b/push/src/test/java/com/xiaomi/xmsf/push/notification/FocusNotificationSafetyTest.java new file mode 100644 index 000000000..15cac200c --- /dev/null +++ b/push/src/test/java/com/xiaomi/xmsf/push/notification/FocusNotificationSafetyTest.java @@ -0,0 +1,389 @@ +package com.xiaomi.xmsf.push.notification; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.junit.Test; + +public class FocusNotificationSafetyTest { + + @Test + public void keepsExistingReadableFieldsAndFillsOnlyMissingField() { + FocusNotificationSafety.ResolvedContent result = + FocusNotificationSafety.resolveReadableContent( + "normal title", "", + "{\"ticker\":\"Ticker\",\"title\":\"Focus title\"," + + "\"description\":\"Focus description\"}", + "Fallback title", "Fallback body"); + + assertEquals("normal title", result.title()); + assertEquals("Focus description", result.body()); + } + + @Test + public void usesTickerWhenFocusTitleAndMetaTitleAreEmpty() { + FocusNotificationSafety.ResolvedContent result = + FocusNotificationSafety.resolveReadableContent( + null, null, + "{\"ticker\":\"Ticker\",\"title\":\"\"," + + "\"description\":\"Description\"}", + "Fallback title", "Fallback body"); + + assertEquals("Ticker", result.title()); + assertEquals("Description", result.body()); + } + + @Test + public void malformedAndOversizedParametersStillProduceSafeText() { + FocusNotificationSafety.ResolvedContent malformed = + FocusNotificationSafety.resolveReadableContent( + " ", "\t", "not-json", "App", "Body"); + assertEquals("App", malformed.title()); + assertEquals("Body", malformed.body()); + + String oversized = "{\"title\":\"" + "x".repeat(4_000) + "\"}"; + FocusNotificationSafety.ResolvedContent tooLarge = + FocusNotificationSafety.resolveReadableContent( + null, null, oversized, "App", "Body"); + assertNotNull(tooLarge.title()); + assertNotNull(tooLarge.body()); + assertFalse(tooLarge.title().isEmpty()); + assertFalse(tooLarge.body().isEmpty()); + } + + @Test + public void boundsFocusParameterByUtf8Bytes() { + assertTrue(FocusNotificationSafety.isParameterWithinLimit("a".repeat(3_072))); + assertFalse(FocusNotificationSafety.isParameterWithinLimit("a".repeat(3_073))); + assertFalse(FocusNotificationSafety.isParameterWithinLimit("😀".repeat(1_000))); + } + + @Test + public void imageEnrichmentUsesSmallGlobalCallerBudget() { + assertTrue(FocusNotificationSafety.IMAGE_ENRICHMENT_BUDGET_MILLIS > 0L); + assertTrue(FocusNotificationSafety.IMAGE_ENRICHMENT_BUDGET_MILLIS <= 750L); + } + + @Test + public void malformedFocusParameterIsRejectedBeforePrivateDelivery() { + assertTrue(FocusNotificationSafety.isWellFormedParameter("{}")); + assertTrue(FocusNotificationSafety.isWellFormedParameter( + "{\"ticker\":\"hello\"}")); + assertFalse(FocusNotificationSafety.isWellFormedParameter("not-json")); + assertFalse(FocusNotificationSafety.isWellFormedParameter("[]")); + assertFalse(FocusNotificationSafety.isWellFormedParameter( + "{\"x\":\"" + "x".repeat(3_100) + "\"}")); + } + + @Test + public void customFocusParameterUsesTheSameBoundedJsonObjectContract() { + assertTrue(FocusNotificationSafety.isWellFormedParameter( + "{\"business\":\"tsmclient\",\"param_island\":{}}")); + assertFalse(FocusNotificationSafety.isWellFormedParameter("\"not-an-object\"")); + assertFalse(FocusNotificationSafety.isWellFormedParameter("[]")); + } + + @Test + public void parsesObservedDeliveryProgressWithoutApplicationSpecificRules() { + int[] observedProgress = {0, 10, 35, 50, 75, 100}; + for (int progress : observedProgress) { + FocusNotificationSafety.PortableFocusData result = + FocusNotificationSafety.parsePortableFocusData( + "{\"business\":\"food_delivery\",\"progress\":" + + progress + ",\"updatable\":true}"); + + assertTrue(result.hasProgress()); + assertEquals(progress, result.progress()); + assertTrue(result.updatable()); + } + } + + @Test + public void parsesNestedProgressAndReadableDeliveryFields() { + FocusNotificationSafety.PortableFocusData result = + FocusNotificationSafety.parsePortableFocusData( + "{\"title\":\"Arrives at 20:12\"," + + "\"content\":\"Courier is delivering\"," + + "\"url\":\"https://example.test/order/42\"," + + "\"sequence\":1787400588773," + + "\"progressCount\":2," + + "\"param_v2\":{\"progressInfo\":{\"progress\":75}}}"); + + assertEquals("Arrives at 20:12", result.title()); + assertEquals("Courier is delivering", result.body()); + assertEquals("https://example.test/order/42", result.url()); + assertEquals("1787400588773", result.sequence()); + assertEquals(2, result.progressCount()); + assertEquals(75, result.progress()); + } + + @Test + public void mapsObservedDeliveryProgressToThreePortableStages() { + FocusNotificationSafety.PortableFocusData waiting = + FocusNotificationSafety.parsePortableFocusData("{\"progress\":0}"); + assertEquals(-1, waiting.stageIndex()); + assertEquals(0, waiting.timelineProgress()); + + int[] merchant = {10, 35, 50, 74}; + for (int progress : merchant) { + FocusNotificationSafety.PortableFocusData result = + FocusNotificationSafety.parsePortableFocusData( + "{\"progress\":" + progress + "}"); + assertEquals(0, result.stageIndex()); + assertEquals(0, result.timelineProgress()); + } + + FocusNotificationSafety.PortableFocusData courier = + FocusNotificationSafety.parsePortableFocusData("{\"progress\":75}"); + assertEquals(1, courier.stageIndex()); + assertEquals(50, courier.timelineProgress()); + + FocusNotificationSafety.PortableFocusData delivered = + FocusNotificationSafety.parsePortableFocusData("{\"progress\":100}"); + assertEquals(2, delivered.stageIndex()); + assertEquals(100, delivered.timelineProgress()); + } + + @Test + public void gatesPortableTimelineByFoodDeliveryScene() { + FocusNotificationSafety.PortableFocusData scene = + FocusNotificationSafety.parsePortableFocusData( + "{\"scene\":\"foodDelivery\",\"progress\":75}"); + assertTrue(scene.isFoodDeliveryTimeline()); + + FocusNotificationSafety.PortableFocusData businessAlias = + FocusNotificationSafety.parsePortableFocusData( + "{\"business\":\"food_delivery\",\"progress\":75}"); + assertTrue(businessAlias.isFoodDeliveryTimeline()); + + FocusNotificationSafety.PortableFocusData unrelated = + FocusNotificationSafety.parsePortableFocusData( + "{\"scene\":\"rideHailing\",\"business\":\"food_delivery\"," + + "\"progress\":75}"); + assertFalse(unrelated.isFoodDeliveryTimeline()); + } + + @Test + public void readsPortableAccentFromObservedProgressInfo() { + FocusNotificationSafety.PortableFocusData result = + FocusNotificationSafety.parsePortableFocusData( + "{\"param_v2\":{\"progressInfo\":{" + + "\"progress\":75,\"colorProgress\":\"#FF6200\"}}}"); + + assertEquals("#FF6200", result.accentColor()); + } + + @Test + public void nestedBaseInfoFillsMissingPortableText() { + FocusNotificationSafety.PortableFocusData result = + FocusNotificationSafety.parsePortableFocusData( + "{\"param_v2\":{\"baseInfo\":{" + + "\"title\":\"Order accepted\"," + + "\"content\":\"Preparing food\"}}}"); + + assertEquals("Order accepted", result.title()); + assertEquals("Preparing food", result.body()); + } + + @Test + public void paramV2AodTitleIsLastReadableTitleFallback() { + FocusNotificationSafety.PortableFocusData result = + FocusNotificationSafety.parsePortableFocusData( + "{\"param_v2\":{\"aodTitle\":\"Delivered\"}}"); + + assertEquals("Delivered", result.title()); + } + + @Test + public void invalidProgressFallsBackOrClampsSafely() { + FocusNotificationSafety.PortableFocusData nestedFallback = + FocusNotificationSafety.parsePortableFocusData( + "{\"progress\":-1,\"param_v2\":{" + + "\"progressInfo\":{\"progress\":35}}}"); + assertEquals(35, nestedFallback.progress()); + + FocusNotificationSafety.PortableFocusData clamped = + FocusNotificationSafety.parsePortableFocusData( + "{\"progress\":1000}"); + assertEquals(100, clamped.progress()); + + FocusNotificationSafety.PortableFocusData missing = + FocusNotificationSafety.parsePortableFocusData( + "{\"progress\":-5}"); + assertFalse(missing.hasProgress()); + } + + @Test + public void malformedAndOversizedPortableDataIsEmpty() { + FocusNotificationSafety.PortableFocusData malformed = + FocusNotificationSafety.parsePortableFocusData("not-json"); + assertFalse(malformed.hasProgress()); + assertNull(malformed.title()); + assertNull(malformed.body()); + assertNull(malformed.url()); + + FocusNotificationSafety.PortableFocusData oversized = + FocusNotificationSafety.parsePortableFocusData( + "{\"content\":\"" + "x".repeat(4_000) + "\"}"); + assertFalse(oversized.hasProgress()); + assertNull(oversized.body()); + } + + @Test + public void contentAliasIsUsedForReadableFallbackBody() { + FocusNotificationSafety.ResolvedContent result = + FocusNotificationSafety.resolveReadableContent( + null, null, + "{\"title\":\"Delivery\",\"content\":\"On the way\"}", + "App", "New notification"); + + assertEquals("Delivery", result.title()); + assertEquals("On the way", result.body()); + } + + @Test + public void findsApplicationIconAliasInsideParamV2AndArrays() { + String parameter = "{\"business\":\"food_delivery\"," + + "\"param_v2\":{\"image\":{\"pic\":\"" + + FocusNotificationSafety.FOCUS_APP_ICON_PICTURE + "\"}}," + + "\"images\":[\"other\",\"" + + FocusNotificationSafety.FOCUS_APP_ICON_PICTURE + "\"]}"; + + assertTrue(FocusNotificationSafety.referencesPictureAlias( + parameter, FocusNotificationSafety.FOCUS_APP_ICON_PICTURE)); + } + + @Test + public void applicationIconAliasDoesNotMatchMalformedOrSubstringValues() { + assertFalse(FocusNotificationSafety.referencesPictureAlias( + "{\"param_v2\":{\"pic\":\"miui.focus.pic_app_icon_extra\"}}", + FocusNotificationSafety.FOCUS_APP_ICON_PICTURE)); + assertFalse(FocusNotificationSafety.referencesPictureAlias( + "not-json", FocusNotificationSafety.FOCUS_APP_ICON_PICTURE)); + assertFalse(FocusNotificationSafety.referencesPictureAlias( + "{\"param_v2\":{\"pic\":\"" + + FocusNotificationSafety.FOCUS_APP_ICON_PICTURE + "\"}}", + "")); + } + + @Test + public void deeplyNestedJsonCannotBreakTheStandardFallback() { + String deeplyNested = "[".repeat(1_200) + "0" + "]".repeat(1_200); + + assertTrue(FocusNotificationSafety.isParameterWithinLimit(deeplyNested)); + assertFalse(FocusNotificationSafety.isWellFormedParameter(deeplyNested)); + + FocusNotificationSafety.ResolvedContent result = + FocusNotificationSafety.resolveReadableContent( + null, null, deeplyNested, "App", "Body"); + assertEquals("App", result.title()); + assertEquals("Body", result.body()); + } + + @Test + public void focusEnrichmentFailureRetriesOnceWithSameIdentity() { + List identities = new ArrayList<>(); + List focusFlags = new ArrayList<>(); + List failures = new ArrayList<>(); + + String result = FocusNotificationSafety.deliverWithSingleFallback( + "client.example", "mipush_client.example", 42, true, + (packageName, tag, id, includeFocus, focusFailure) -> { + identities.add(packageName + "|" + tag + "|" + id); + focusFlags.add(includeFocus); + failures.add(focusFailure); + if (includeFocus) { + // Models image/Bundle/Binder failure from the optional path. + throw new IllegalStateException("focus enrichment failed"); + } + return "standard"; + }); + + assertEquals("standard", result); + assertEquals(Arrays.asList( + "client.example|mipush_client.example|42", + "client.example|mipush_client.example|42"), identities); + assertEquals(Arrays.asList(true, false), focusFlags); + assertEquals(null, failures.get(0)); + assertNotNull(failures.get(1)); + } + + @Test + public void disabledFocusDoesNotRetryOrTouchStandardPath() { + int[] calls = {0}; + String result = FocusNotificationSafety.deliverWithSingleFallback( + "client.example", "tag", 7, false, + (packageName, tag, id, includeFocus, focusFailure) -> { + calls[0]++; + assertFalse(includeFocus); + assertEquals(null, focusFailure); + return "standard"; + }); + + assertEquals("standard", result); + assertEquals(1, calls[0]); + } + + @Test + public void fallbackFailureIsNotRetriedAgain() { + int[] calls = {0}; + try { + FocusNotificationSafety.deliverWithSingleFallback( + "client.example", "tag", 9, true, + (packageName, tag, id, includeFocus, focusFailure) -> { + calls[0]++; + throw new IllegalStateException(includeFocus + ? "focus failed" : "standard failed"); + }); + fail("fallback failure must propagate"); + } catch (IllegalStateException expected) { + assertEquals("standard failed", expected.getMessage()); + } + assertEquals(2, calls[0]); + } + + @Test + public void focusExtraPrefixCoversParamPicturesAndNativeBundle() { + Map extras = new HashMap<>(); + extras.put("miui.focus.param", "{}"); + extras.put("miui.focus.pic_0", "https://example.test/p.png"); + extras.put("miui.focus.pics", "native-icons"); + extras.put("ordinary.key", "keep"); + + for (String key : new ArrayList<>(extras.keySet())) { + if (FocusNotificationSafety.isFocusExtraKey(key)) { + extras.remove(key); + } + } + + assertEquals(1, extras.size()); + assertTrue(extras.containsKey("ordinary.key")); + } + + @Test + public void stableFocusGroupIsDeterministic() { + assertEquals("client.example_#focus#", + FocusNotificationSafety.stableFocusGroup("client.example")); + assertEquals(FocusNotificationSafety.stableFocusGroup("client.example"), + FocusNotificationSafety.stableFocusGroup("client.example")); + } + + @Test + public void onlyUngroupedFocusPayloadUsesIsolatedGroup() { + assertTrue(FocusNotificationSafety.shouldIsolateFocusGroup(null, true)); + assertTrue(FocusNotificationSafety.shouldIsolateFocusGroup("", true)); + assertFalse(FocusNotificationSafety.shouldIsolateFocusGroup( + "client.example_#group#_official", true)); + assertFalse(FocusNotificationSafety.shouldIsolateFocusGroup(null, false)); + } +} diff --git a/push/src/test/java/com/xiaomi/xmsf/push/notification/FocusProtocolSupportCacheTest.java b/push/src/test/java/com/xiaomi/xmsf/push/notification/FocusProtocolSupportCacheTest.java new file mode 100644 index 000000000..b1ecf84df --- /dev/null +++ b/push/src/test/java/com/xiaomi/xmsf/push/notification/FocusProtocolSupportCacheTest.java @@ -0,0 +1,51 @@ +package com.xiaomi.xmsf.push.notification; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.Test; + +public class FocusProtocolSupportCacheTest { + private static final long TTL_MS = 300_000L; + + @Test + public void cachesResolvedCapabilityInsideTtl() { + FocusProtocolSupportCache cache = new FocusProtocolSupportCache(TTL_MS); + AtomicInteger resolutions = new AtomicInteger(); + + assertTrue(cache.get(1_000L, () -> { + resolutions.incrementAndGet(); + return true; + })); + assertTrue(cache.get(1_000L + TTL_MS - 1L, () -> { + resolutions.incrementAndGet(); + return false; + })); + assertEquals(1, resolutions.get()); + } + + @Test + public void refreshesAtBoundaryAndAfterElapsedRealtimeRollback() { + FocusProtocolSupportCache cache = new FocusProtocolSupportCache(TTL_MS); + + assertFalse(cache.get(10_000L, () -> false)); + assertTrue(cache.get(10_000L + TTL_MS, () -> true)); + assertFalse(cache.get(100L, () -> false)); + } + + @Test(expected = IllegalArgumentException.class) + public void rejectsNonPositiveTtl() { + new FocusProtocolSupportCache(0L); + } + + @Test + public void sentinelIsNeverFresh() { + assertFalse(FocusProtocolSupportCache.isFresh( + FocusProtocolSupportCache.NO_CACHED_VALUE, + Long.MAX_VALUE, + TTL_MS)); + } +} diff --git a/push/src/test/java/com/xiaomi/xmsf/push/service/receivers/KeepAliveReceiverTest.java b/push/src/test/java/com/xiaomi/xmsf/push/service/receivers/KeepAliveReceiverTest.java new file mode 100644 index 000000000..9671f3639 --- /dev/null +++ b/push/src/test/java/com/xiaomi/xmsf/push/service/receivers/KeepAliveReceiverTest.java @@ -0,0 +1,31 @@ +package com.xiaomi.xmsf.push.service.receivers; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public class KeepAliveReceiverTest { + @Test + public void permitsFirstScreenWakeImmediately() { + assertTrue(KeepAliveReceiver.shouldStart(0L, 1L)); + } + + @Test + public void suppressesRepeatedScreenWakeWithinInterval() { + assertFalse(KeepAliveReceiver.shouldStart(1_000L, + 1_000L + KeepAliveReceiver.MIN_START_INTERVAL_MS - 1)); + } + + @Test + public void permitsRecoveryAtIntervalBoundary() { + assertTrue(KeepAliveReceiver.shouldStart(1_000L, + 1_000L + KeepAliveReceiver.MIN_START_INTERVAL_MS)); + } + + @Test + public void runningServiceDoesNotRequireAnotherRecoveryStart() { + assertFalse(KeepAliveReceiver.shouldAttemptRecoveryForServiceState(true)); + assertTrue(KeepAliveReceiver.shouldAttemptRecoveryForServiceState(false)); + } +} diff --git a/push/src/test/java/com/xiaomi/xmsf/push/service/receivers/NetworkStatusReceiverTest.java b/push/src/test/java/com/xiaomi/xmsf/push/service/receivers/NetworkStatusReceiverTest.java new file mode 100644 index 000000000..ddca80f09 --- /dev/null +++ b/push/src/test/java/com/xiaomi/xmsf/push/service/receivers/NetworkStatusReceiverTest.java @@ -0,0 +1,85 @@ +package com.xiaomi.xmsf.push.service.receivers; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public class NetworkStatusReceiverTest { + @Test + public void offlineBroadcastNeverStartsRecovery() { + assertFalse(NetworkStatusReceiver.shouldAttemptRecovery( + false, + false, + NetworkStatusReceiver.NO_RECOVERY_ATTEMPT, + 1L)); + } + + @Test + public void runningServiceNeverStartsRecovery() { + assertFalse(NetworkStatusReceiver.shouldAttemptRecovery( + true, + true, + NetworkStatusReceiver.NO_RECOVERY_ATTEMPT, + 1L)); + } + + @Test + public void firstOnlineBroadcastCanRecoverDeadService() { + assertTrue(NetworkStatusReceiver.shouldAttemptRecovery( + true, + false, + NetworkStatusReceiver.NO_RECOVERY_ATTEMPT, + 1L)); + } + + @Test + public void repeatedBroadcastInsideIntervalIsSuppressed() { + assertFalse(NetworkStatusReceiver.shouldAttemptRecovery( + true, + false, + 1_000L, + 1_000L + NetworkStatusReceiver.MIN_RECOVERY_INTERVAL_MS - 1L)); + } + + @Test + public void recoveryIsAllowedAtIntervalBoundary() { + assertTrue(NetworkStatusReceiver.shouldAttemptRecovery( + true, + false, + 1_000L, + 1_000L + NetworkStatusReceiver.MIN_RECOVERY_INTERVAL_MS)); + } + + @Test + public void elapsedRealtimeRollbackAllowsRecovery() { + assertTrue(NetworkStatusReceiver.shouldAttemptRecovery( + true, + false, + 90_000L, + 100L)); + } + + @Test + public void registrationRefreshIsSuppressedDuringConnectivityBurst() { + assertFalse(NetworkStatusReceiver.shouldProcessRegistration( + true, + 1_000L, + 1_000L + NetworkStatusReceiver.MIN_REGISTRATION_PROCESS_INTERVAL_MS - 1L)); + } + + @Test + public void registrationRefreshRunsAtIntervalBoundary() { + assertTrue(NetworkStatusReceiver.shouldProcessRegistration( + true, + 1_000L, + 1_000L + NetworkStatusReceiver.MIN_REGISTRATION_PROCESS_INTERVAL_MS)); + } + + @Test + public void registrationRefreshRequiresNetwork() { + assertFalse(NetworkStatusReceiver.shouldProcessRegistration(false, + NetworkStatusReceiver.NO_RECOVERY_ATTEMPT, + 1L)); + } +} diff --git a/push/src/test/java/com/xiaomi/xmsf/push/utils/ConfigurationDiagnosticsSnapshotTest.java b/push/src/test/java/com/xiaomi/xmsf/push/utils/ConfigurationDiagnosticsSnapshotTest.java new file mode 100644 index 000000000..6faa178f0 --- /dev/null +++ b/push/src/test/java/com/xiaomi/xmsf/push/utils/ConfigurationDiagnosticsSnapshotTest.java @@ -0,0 +1,157 @@ +package com.xiaomi.xmsf.push.utils; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import com.elvishew.xlog.XLog; + +import org.json.JSONException; +import org.junit.Before; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +public class ConfigurationDiagnosticsSnapshotTest { + @Before + public void setUp() { + XLog.init(); + } + + @Test + public void loaderStartsNotConfiguredWithSharedImmutableEmptyList() { + ConfigurationsLoader loader = new ConfigurationsLoader(); + + ConfigurationDiagnosticsSnapshot snapshot = loader.getDiagnosticsSnapshot(); + + assertEquals( + ConfigurationDiagnosticsSnapshot.Status.NOT_CONFIGURED, + snapshot.getStatus()); + assertTrue(snapshot.getUnresolvedReferences().isEmpty()); + assertSame(snapshot.getUnresolvedReferences(), loader.getUnresolvedReferences()); + assertThrows( + UnsupportedOperationException.class, + () -> snapshot.getUnresolvedReferences().add(diagnostic("missing"))); + } + + @Test + public void readySnapshotCanContainNoUnresolvedReferences() { + ConfigurationDiagnosticsSnapshot snapshot = + ConfigurationDiagnosticsSnapshot.ready(Collections.emptyList()); + + assertEquals(ConfigurationDiagnosticsSnapshot.Status.READY, snapshot.getStatus()); + assertTrue(snapshot.getUnresolvedReferences().isEmpty()); + } + + @Test + public void readySnapshotDefensivelyCopiesUnresolvedReferences() { + List source = new ArrayList<>(); + source.add(diagnostic("missing")); + + ConfigurationDiagnosticsSnapshot snapshot = + ConfigurationDiagnosticsSnapshot.ready(source); + source.clear(); + + assertEquals(ConfigurationDiagnosticsSnapshot.Status.READY, snapshot.getStatus()); + assertEquals(1, snapshot.getUnresolvedReferences().size()); + assertEquals("missing", snapshot.getUnresolvedReferences().get(0).getReference()); + } + + @Test + public void failedSnapshotCannotExposeStaleOrPartialDiagnostics() { + ConfigurationDiagnosticsSnapshot previous = ConfigurationDiagnosticsSnapshot.ready( + Collections.singletonList(diagnostic("stale-reference"))); + + ConfigurationDiagnosticsSnapshot failed = ConfigurationDiagnosticsSnapshot.failed(); + + assertEquals(1, previous.getUnresolvedReferences().size()); + assertEquals(ConfigurationDiagnosticsSnapshot.Status.FAILED, failed.getStatus()); + assertTrue(failed.getUnresolvedReferences().isEmpty()); + } + + @Test + public void successfulMemoryLoadPublishesReadyStateAndMissingReference() + throws JSONException { + ConfigurationsLoader loader = new ConfigurationsLoader(); + + loader.load( + "memory-source.json", + "{\"version\":\"1\",\"configs\":{\"consumer\":[\"missing-rule\"]}}"); + + ConfigurationDiagnosticsSnapshot snapshot = loader.getDiagnosticsSnapshot(); + assertEquals(ConfigurationDiagnosticsSnapshot.Status.READY, snapshot.getStatus()); + assertEquals(1, snapshot.getUnresolvedReferences().size()); + assertEquals( + "memory-source.json", + snapshot.getUnresolvedReferences().get(0).getSourceName()); + assertTrue(loader.getConfigs().containsKey("consumer")); + assertSame(snapshot.getUnresolvedReferences(), loader.getUnresolvedReferences()); + assertThrows( + UnsupportedOperationException.class, + () -> loader.getConfigs().get("consumer").add("another-reference")); + } + + @Test + public void successfulEmptyMemoryLoadPublishesReadyWithNoDiagnostics() + throws JSONException { + ConfigurationsLoader loader = new ConfigurationsLoader(); + + loader.load("{\"version\":\"1\",\"configs\":{}}"); + + assertEquals( + ConfigurationDiagnosticsSnapshot.Status.READY, + loader.getDiagnosticsSnapshot().getStatus()); + assertTrue(loader.getDiagnosticsSnapshot().getUnresolvedReferences().isEmpty()); + assertTrue(loader.getConfigs().isEmpty()); + } + + @Test + public void nullDirectoryRequestResetsPublishedStateToNotConfigured() + throws JSONException { + ConfigurationsLoader loader = new ConfigurationsLoader(); + loader.load( + "good.json", + "{\"version\":\"1\",\"configs\":{\"consumer\":[\"missing-rule\"]}}"); + + assertTrue(!loader.init(null, null)); + + assertEquals( + ConfigurationDiagnosticsSnapshot.Status.NOT_CONFIGURED, + loader.getDiagnosticsSnapshot().getStatus()); + assertTrue(loader.getDiagnosticsSnapshot().getUnresolvedReferences().isEmpty()); + assertTrue(loader.getConfigs().isEmpty()); + } + + @Test + public void failedMemoryLoadPreservesPreviouslyPublishedSnapshotAndConfigs() + throws JSONException { + ConfigurationsLoader loader = new ConfigurationsLoader(); + loader.load( + "good.json", + "{\"version\":\"1\",\"configs\":{\"consumer\":[\"missing-rule\"]}}"); + ConfigurationDiagnosticsSnapshot previousSnapshot = loader.getDiagnosticsSnapshot(); + Map> previousConfigs = loader.getConfigs(); + + assertThrows( + JSONException.class, + () -> loader.load( + "bad.json", + "{\"version\":\"2\",\"configs\":{\"partial\":[],\"broken\":{}}}")); + + assertSame(previousSnapshot, loader.getDiagnosticsSnapshot()); + assertSame(previousConfigs, loader.getConfigs()); + assertEquals(1, loader.getUnresolvedReferences().size()); + assertTrue(loader.getConfigs().containsKey("consumer")); + assertTrue(!loader.getConfigs().containsKey("partial")); + } + + private static ConfigurationReferenceDiagnostics.UnresolvedReference diagnostic( + String reference) { + return ConfigurationReferenceDiagnostics.referenceSite( + "source.json", "owner", reference); + } +} diff --git a/push/src/test/java/com/xiaomi/xmsf/push/utils/ConfigurationsLoaderReferenceDiagnosticTest.java b/push/src/test/java/com/xiaomi/xmsf/push/utils/ConfigurationsLoaderReferenceDiagnosticTest.java new file mode 100644 index 000000000..a297fb6d1 --- /dev/null +++ b/push/src/test/java/com/xiaomi/xmsf/push/utils/ConfigurationsLoaderReferenceDiagnosticTest.java @@ -0,0 +1,62 @@ +package com.xiaomi.xmsf.push.utils; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; + +public class ConfigurationsLoaderReferenceDiagnosticTest { + @Test + public void reportsOnlyMissingReferencesWithoutPayload() { + ConfigurationReferenceDiagnostics.UnresolvedReference missing = diagnostic( + "2_post_config.json", "com.example.app", "$-direct-open-intent"); + ConfigurationReferenceDiagnostics.UnresolvedReference valid = diagnostic( + "consumer.json", "com.example.app", "shared-rule"); + + List diagnostics = + ConfigurationReferenceDiagnostics.resolve( + new HashSet<>(Arrays.asList("shared-rule", "private notification message")), + Arrays.asList(missing, valid)); + + assertEquals(1, diagnostics.size()); + ConfigurationReferenceDiagnostics.UnresolvedReference diagnostic = diagnostics.get(0); + assertEquals("2_post_config.json", diagnostic.getSourceName()); + assertEquals("com.example.app", diagnostic.getOwnerKey()); + assertEquals("$-direct-open-intent", diagnostic.getReference()); + assertTrue(!diagnostic.toString().contains("private notification message")); + } + + @Test + public void validReferenceProducesNoDiagnostic() { + ConfigurationReferenceDiagnostics.UnresolvedReference valid = diagnostic( + "consumer.json", "com.example.app", "shared-rule"); + + assertTrue(ConfigurationReferenceDiagnostics.resolve( + new HashSet<>(Arrays.asList("shared-rule")), + Arrays.asList(valid)).isEmpty()); + } + + @Test + public void diagnosticsAreSortedAndDeduplicated() { + ConfigurationReferenceDiagnostics.UnresolvedReference zRule = diagnostic( + "consumer.json", "owner", "z-rule"); + ConfigurationReferenceDiagnostics.UnresolvedReference aRule = diagnostic( + "consumer.json", "owner", "a-rule"); + + List diagnostics = + ConfigurationReferenceDiagnostics.resolve( + new HashSet<>(), Arrays.asList(zRule, aRule, zRule)); + assertEquals(2, diagnostics.size()); + assertEquals("a-rule", diagnostics.get(0).getReference()); + assertEquals("z-rule", diagnostics.get(1).getReference()); + } + + private static ConfigurationReferenceDiagnostics.UnresolvedReference diagnostic( + String sourceName, String ownerKey, String reference) { + return ConfigurationReferenceDiagnostics.referenceSite(sourceName, ownerKey, reference); + } +} diff --git a/push/src/test/java/com/xiaomi/xmsf/utils/ConfigCenterStartForegroundServiceTest.java b/push/src/test/java/com/xiaomi/xmsf/utils/ConfigCenterStartForegroundServiceTest.java new file mode 100644 index 000000000..cedbd00b4 --- /dev/null +++ b/push/src/test/java/com/xiaomi/xmsf/utils/ConfigCenterStartForegroundServiceTest.java @@ -0,0 +1,34 @@ +package com.xiaomi.xmsf.utils; + +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.content.Context; +import android.content.SharedPreferences; + +import com.xiaomi.xmsf.BuildConfig; + +import org.junit.Test; + +public class ConfigCenterStartForegroundServiceTest { + + @Test + public void readsCanonicalForegroundServicePreference() { + Context context = mock(Context.class); + SharedPreferences preferences = mock(SharedPreferences.class); + when(context.getSharedPreferences( + eq(BuildConfig.APPLICATION_ID + "_preferences"), eq(Context.MODE_MULTI_PROCESS))) + .thenReturn(preferences); + when(preferences.getBoolean(ConfigCenter.KEY_START_FOREGROUND_SERVICE, false)) + .thenReturn(true); + + assertTrue(new ConfigCenter().isStartForegroundService(context)); + + verify(preferences).getBoolean("StartForegroundService", false); + verify(preferences, never()).getBoolean("key_start_as_foreground", false); + } +} diff --git a/push/src/test/java/test/com/nihility/service/service/MessengerAbilityTest.java b/push/src/test/java/test/com/nihility/service/service/MessengerAbilityTest.java index 269d5590d..c0761fbef 100644 --- a/push/src/test/java/test/com/nihility/service/service/MessengerAbilityTest.java +++ b/push/src/test/java/test/com/nihility/service/service/MessengerAbilityTest.java @@ -26,4 +26,11 @@ public void broadcastAtConnectionStatusChanged() { verify(messenger).notifyConnectionStatusChanged(ConnectionStatus.connected.ordinal()); } + @Test + public void unregisterMessengerAfterServiceDestroy() { + listener.destroy(); + + verify(messenger).close(); + } + } diff --git a/push/src/test/java/test/com/nihility/service/service/XMPushServiceListenerNotifierTest.java b/push/src/test/java/test/com/nihility/service/service/XMPushServiceListenerNotifierTest.java index 487531935..b2a428251 100644 --- a/push/src/test/java/test/com/nihility/service/service/XMPushServiceListenerNotifierTest.java +++ b/push/src/test/java/test/com/nihility/service/service/XMPushServiceListenerNotifierTest.java @@ -1,7 +1,10 @@ package test.com.nihility.service.service; import static com.nihility.service.XMPushServiceListener.ConnectionStatus.connecting; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doThrow; import android.content.Intent; @@ -37,6 +40,43 @@ public void invokeListenersForDestroy() { verify(listener).destroy(); } + @Test + public void releaseListenersAfterDestroy() { + notifier.destroy(); + notifier.destroy(); + + verify(listener, times(1)).destroy(); + } + + @Test + public void listenerMayBeAddedDuringNotification() { + XMPushServiceListener addedListener = org.mockito.Mockito.mock( + XMPushServiceListener.class); + doAnswer(invocation -> { + notifier.addListener(addedListener); + return null; + }).when(listener).created(); + + notifier.created(); + + verify(listener).created(); + verify(addedListener, times(0)).created(); + } + + @Test(expected = IllegalStateException.class) + public void destroyContinuesCleanupAfterListenerFailure() { + XMPushServiceListener secondListener = org.mockito.Mockito.mock( + XMPushServiceListener.class); + notifier.addListener(secondListener); + doThrow(new IllegalStateException("failure")).when(listener).destroy(); + + try { + notifier.destroy(); + } finally { + verify(secondListener).destroy(); + } + } + @Test public void invokeListenersForStart() { Intent intent = new Intent(); diff --git a/push/src/test/java/test/top/trumeet/mipushframework/main/subpage/EventListPageUtilsTest.java b/push/src/test/java/test/top/trumeet/mipushframework/main/subpage/EventListPageUtilsTest.java index 968611469..39b2edcec 100644 --- a/push/src/test/java/test/top/trumeet/mipushframework/main/subpage/EventListPageUtilsTest.java +++ b/push/src/test/java/test/top/trumeet/mipushframework/main/subpage/EventListPageUtilsTest.java @@ -1,11 +1,16 @@ package test.top.trumeet.mipushframework.main.subpage; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + import com.xiaomi.xmpush.thrift.XmPushActionContainer; import org.junit.Test; import java.util.Set; +import top.trumeet.mipush.provider.entities.Event; import top.trumeet.mipushframework.main.subpage.EventListPageUtils; public class EventListPageUtilsTest { @@ -29,4 +34,35 @@ protected boolean isNotificationDisabled(XmPushActionContainer container) { set.add("test"); } } -} \ No newline at end of file + + @Test + public void replayStatusRejectsNonNotificationRecords() { + Event registration = new Event(); + registration.setType(Event.Type.Registration); + registration.setPayload(new byte[]{1}); + + assertFalse(EventListPageUtils.isReplayableEvent(registration)); + assertEquals( + EventListPageUtils.ReplayStatus.UNSUPPORTED_EVENT, + EventListPageUtils.getReplayStatus(registration, true)); + } + + @Test + public void replayStatusRequiresPayloadAndRunningService() { + Event notification = new Event(); + notification.setType(Event.Type.SendMessage); + + assertTrue(EventListPageUtils.isReplayableEvent(notification)); + assertEquals( + EventListPageUtils.ReplayStatus.INVALID_PAYLOAD, + EventListPageUtils.getReplayStatus(notification, true)); + + notification.setPayload(new byte[]{1}); + assertEquals( + EventListPageUtils.ReplayStatus.SERVICE_UNAVAILABLE, + EventListPageUtils.getReplayStatus(notification, false)); + assertEquals( + EventListPageUtils.ReplayStatus.READY, + EventListPageUtils.getReplayStatus(notification, true)); + } +} diff --git a/push/src/test/java/top/trumeet/mipush/provider/db/RegistrationEvidenceResolverTest.java b/push/src/test/java/top/trumeet/mipush/provider/db/RegistrationEvidenceResolverTest.java new file mode 100644 index 000000000..618a91230 --- /dev/null +++ b/push/src/test/java/top/trumeet/mipush/provider/db/RegistrationEvidenceResolverTest.java @@ -0,0 +1,101 @@ +package top.trumeet.mipush.provider.db; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import com.xiaomi.xmpush.thrift.XmPushActionRegistrationResult; + +import org.junit.Test; + +import top.trumeet.mipush.provider.entities.Event; +import top.trumeet.mipush.provider.entities.RegisteredApplication; + +public class RegistrationEvidenceResolverTest { + @Test + public void verifiedRuntimeSuccessRepairsStaleSdkState() { + assertEquals( + RegisteredApplication.RegisteredType.Registered, + RegistrationEvidenceResolver.resolve(true, true, true, false, false)); + } + + @Test + public void persistedSdkStatePrecedesOlderNegativeEvents() { + assertEquals( + RegisteredApplication.RegisteredType.Unregistered, + RegistrationEvidenceResolver.resolve(true, true, false, true, true)); + assertEquals( + RegisteredApplication.RegisteredType.Registered, + RegistrationEvidenceResolver.resolve(false, true, false, false, true)); + } + + @Test + public void explicitUnregistrationRejectsLateReceiveEvidence() { + assertEquals( + RegisteredApplication.RegisteredType.Unregistered, + RegistrationEvidenceResolver.resolve(true, true, false, true, false)); + } + + @Test + public void latestEventDecidesWhenCurrentSdkStateIsSilent() { + assertEquals( + RegisteredApplication.RegisteredType.Registered, + RegistrationEvidenceResolver.resolve(false, false, true, false, false)); + assertEquals( + RegisteredApplication.RegisteredType.Unregistered, + RegistrationEvidenceResolver.resolve(false, false, false, false, true)); + assertEquals( + RegisteredApplication.RegisteredType.NotRegistered, + RegistrationEvidenceResolver.resolve(false, false, false, false, false)); + } + + @Test + public void unregistrationIsNegativeControlEvidence() { + assertEquals( + RegistrationEvidenceResolver.EventEvidence.NEGATIVE, + RegistrationEvidenceResolver.classifyEvent(Event.Type.UnRegistration, null)); + } + + @Test + public void receiveTimeMustBeStrictlyNewerThanControlEvidence() { + assertTrue(RegistrationEvidenceResolver.isReceiveEvidenceNewer(20L, 10L)); + assertFalse(RegistrationEvidenceResolver.isReceiveEvidenceNewer(10L, 10L)); + assertFalse(RegistrationEvidenceResolver.isReceiveEvidenceNewer(9L, 10L)); + assertTrue(RegistrationEvidenceResolver.isReceiveEvidenceNewer(1L, null)); + assertFalse(RegistrationEvidenceResolver.isReceiveEvidenceNewer(0L, null)); + } + + @Test + public void persistedUnregisteredSetSurvivesColdStartParsing() { + assertEquals( + new java.util.HashSet<>(java.util.Arrays.asList( + "com.example.first", "com.example.second")), + RegistrationEvidenceResolver.parsePersistedPackageSet( + " com.example.first,,com.example.second,com.example.first ")); + assertTrue(RegistrationEvidenceResolver.parsePersistedPackageSet("").isEmpty()); + assertTrue(RegistrationEvidenceResolver.parsePersistedPackageSet(null).isEmpty()); + } + + @Test + public void registrationResultMustDecodeAndSetErrorCode() { + assertEquals( + RegistrationEvidenceResolver.EventEvidence.UNKNOWN, + RegistrationEvidenceResolver.classifyEvent( + Event.Type.RegistrationResult, null)); + assertEquals( + RegistrationEvidenceResolver.EventEvidence.UNKNOWN, + RegistrationEvidenceResolver.classifyEvent( + Event.Type.RegistrationResult, + new XmPushActionRegistrationResult())); + assertEquals( + RegistrationEvidenceResolver.EventEvidence.POSITIVE, + RegistrationEvidenceResolver.classifyEvent( + Event.Type.RegistrationResult, + new XmPushActionRegistrationResult().setErrorCode(0))); + assertEquals( + RegistrationEvidenceResolver.EventEvidence.NEGATIVE, + RegistrationEvidenceResolver.classifyEvent( + Event.Type.RegistrationResult, + new XmPushActionRegistrationResult().setErrorCode(1))); + } +} diff --git a/push/src/test/java/top/trumeet/mipushframework/main/AppConfigurationUtilsFakeSuggestionTest.java b/push/src/test/java/top/trumeet/mipushframework/main/AppConfigurationUtilsFakeSuggestionTest.java new file mode 100644 index 000000000..a270eb86b --- /dev/null +++ b/push/src/test/java/top/trumeet/mipushframework/main/AppConfigurationUtilsFakeSuggestionTest.java @@ -0,0 +1,16 @@ +package top.trumeet.mipushframework.main; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public class AppConfigurationUtilsFakeSuggestionTest { + @Test + public void eligibilityDependsOnlyOnInstallTypeAndSelfIdentity() { + assertTrue(AppConfigurationUtils.isFakeSuggestionEligible(true, false)); + assertFalse(AppConfigurationUtils.isFakeSuggestionEligible(false, false)); + assertFalse(AppConfigurationUtils.isFakeSuggestionEligible(true, true)); + assertFalse(AppConfigurationUtils.isFakeSuggestionEligible(false, true)); + } +} diff --git a/push/src/test/java/top/trumeet/mipushframework/main/ApplicationInfoPageRegistrationActionTest.kt b/push/src/test/java/top/trumeet/mipushframework/main/ApplicationInfoPageRegistrationActionTest.kt new file mode 100644 index 000000000..03e2ab226 --- /dev/null +++ b/push/src/test/java/top/trumeet/mipushframework/main/ApplicationInfoPageRegistrationActionTest.kt @@ -0,0 +1,30 @@ +package top.trumeet.mipushframework.main + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import top.trumeet.mipush.provider.entities.RegisteredApplication.RegisteredType + +class ApplicationInfoPageRegistrationActionTest { + + @Test + fun notRegisteredOffersExplicitRegistration() { + assertEquals( + RegistrationAction.REGISTER, + registrationActionFor(RegisteredType.NotRegistered), + ) + } + + @Test + fun registrationErrorOffersReregistration() { + assertEquals( + RegistrationAction.REREGISTER, + registrationActionFor(RegisteredType.Unregistered), + ) + } + + @Test + fun registeredAppDoesNotOfferDestructiveRegistrationAction() { + assertNull(registrationActionFor(RegisteredType.Registered)) + } +} diff --git a/push/src/test/java/top/trumeet/mipushframework/main/ApplicationListFilterTest.kt b/push/src/test/java/top/trumeet/mipushframework/main/ApplicationListFilterTest.kt new file mode 100644 index 000000000..c2ebd706f --- /dev/null +++ b/push/src/test/java/top/trumeet/mipushframework/main/ApplicationListFilterTest.kt @@ -0,0 +1,29 @@ +package top.trumeet.mipushframework.main + +import org.junit.Assert.assertEquals +import org.junit.Test +import top.trumeet.mipush.provider.entities.RegisteredApplication +import top.trumeet.mipushframework.main.subpage.ApplicationFilter +import top.trumeet.mipushframework.main.subpage.filterApplicationsForDisplay + +class ApplicationListFilterTest { + private fun app(type: Int, services: Boolean): RegisteredApplication = + RegisteredApplication(null, "pkg$type$services", RegisteredApplication.Type.ASK, true, type, "app") + .also { it.existServices = services } + + @Test + fun categoriesUseRegistrationFactEvenWhenServiceProbeFails() { + val registered = app(RegisteredApplication.RegisteredType.Registered, false) + assertEquals(listOf(registered), filterApplicationsForDisplay(listOf(registered), ApplicationFilter.Registered)) + assertEquals(emptyList(), filterApplicationsForDisplay(listOf(registered), ApplicationFilter.Unregistered)) + } + + @Test + fun problemAndNotRegisteredRemainSeparate() { + val problem = app(RegisteredApplication.RegisteredType.Unregistered, false) + val notRegistered = app(RegisteredApplication.RegisteredType.NotRegistered, true) + val all = listOf(problem, notRegistered) + assertEquals(listOf(problem), filterApplicationsForDisplay(all, ApplicationFilter.Unregistered)) + assertEquals(listOf(notRegistered), filterApplicationsForDisplay(all, ApplicationFilter.NotRegistered)) + } +} diff --git a/push/src/test/java/top/trumeet/mipushframework/main/ApplicationPageServiceProbeMappingTest.kt b/push/src/test/java/top/trumeet/mipushframework/main/ApplicationPageServiceProbeMappingTest.kt new file mode 100644 index 000000000..6293f5ecc --- /dev/null +++ b/push/src/test/java/top/trumeet/mipushframework/main/ApplicationPageServiceProbeMappingTest.kt @@ -0,0 +1,36 @@ +package top.trumeet.mipushframework.main + +import com.elvishew.xlog.XLog +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import top.trumeet.mipush.provider.entities.RegisteredApplication.ServiceProbeState +import top.trumeet.mipushframework.main.subpage.ApplicationPageOperation +import top.trumeet.mipushframework.utils.MiPushManifestChecker.ServiceCheckResult + +class ApplicationPageServiceProbeMappingTest { + @Before + fun initializeLogging() { + XLog.init() + } + + @Test + fun checkerResultMapsToUiProbeState() { + assertEquals( + ServiceProbeState.PRESENT, + ApplicationPageOperation.mapServiceCheckResult(ServiceCheckResult.PRESENT), + ) + assertEquals( + ServiceProbeState.MISSING, + ApplicationPageOperation.mapServiceCheckResult(ServiceCheckResult.MISSING), + ) + assertEquals( + ServiceProbeState.UNKNOWN, + ApplicationPageOperation.mapServiceCheckResult(ServiceCheckResult.UNKNOWN), + ) + assertEquals( + ServiceProbeState.UNKNOWN, + ApplicationPageOperation.mapServiceCheckResult(null), + ) + } +} diff --git a/push/src/test/java/top/trumeet/mipushframework/main/EventListHeaderTextTest.kt b/push/src/test/java/top/trumeet/mipushframework/main/EventListHeaderTextTest.kt new file mode 100644 index 000000000..188ffa364 --- /dev/null +++ b/push/src/test/java/top/trumeet/mipushframework/main/EventListHeaderTextTest.kt @@ -0,0 +1,27 @@ +package top.trumeet.mipushframework.main + +import org.junit.Assert.assertEquals +import org.junit.Test +import top.trumeet.mipushframework.main.subpage.eventHeaderText + +class EventListHeaderTextTest { + @Test + fun longChannelIsPreservedForUiEllipsis() { + val channel = "这是一个非常长的通知频道标题".repeat(20) + + assertEquals(channel, eventHeaderText(emptySet(), channel)) + } + + @Test + fun configurationAndChannelShareOneBoundedHeader() { + val options = linkedSetOf("first-option", "second-option") + val channel = "notification-channel" + + assertEquals("[first-option, second-option] notification-channel", eventHeaderText(options, channel)) + } + + @Test + fun emptyChannelDoesNotAddTrailingWhitespace() { + assertEquals("[disable]", eventHeaderText(setOf("disable"), "")) + } +} diff --git a/push/src/test/java/top/trumeet/mipushframework/main/MainPageSwipeNavigationTest.kt b/push/src/test/java/top/trumeet/mipushframework/main/MainPageSwipeNavigationTest.kt new file mode 100644 index 000000000..f925aea40 --- /dev/null +++ b/push/src/test/java/top/trumeet/mipushframework/main/MainPageSwipeNavigationTest.kt @@ -0,0 +1,36 @@ +package top.trumeet.mipushframework.main + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class MainPageSwipeNavigationTest { + private val routes = listOf("records", "apps", "settings") + + @Test + fun leftSwipeAdvancesAndRightSwipeReturns() { + assertEquals( + "settings", + routeAfterHorizontalSwipe("apps", dragDistancePx = -100f, thresholdPx = 72f, routes), + ) + assertEquals( + "records", + routeAfterHorizontalSwipe("apps", dragDistancePx = 100f, thresholdPx = 72f, routes), + ) + } + + @Test + fun shortSwipeAndEdgeSwipeDoNotNavigate() { + assertNull(routeAfterHorizontalSwipe("apps", 71f, 72f, routes)) + assertNull(routeAfterHorizontalSwipe("records", 100f, 72f, routes)) + assertNull(routeAfterHorizontalSwipe("settings", -100f, 72f, routes)) + } + + @Test + fun unknownOrInvalidInputIsIgnored() { + assertNull(routeAfterHorizontalSwipe("missing", -100f, 72f, routes)) + assertNull(routeAfterHorizontalSwipe(null, -100f, 72f, routes)) + assertNull(routeAfterHorizontalSwipe("apps", Float.NaN, 72f, routes)) + assertNull(routeAfterHorizontalSwipe("apps", -100f, Float.POSITIVE_INFINITY, routes)) + } +} diff --git a/push/src/test/java/top/trumeet/mipushframework/main/RegistrationStateStyleTest.kt b/push/src/test/java/top/trumeet/mipushframework/main/RegistrationStateStyleTest.kt new file mode 100644 index 000000000..4e7a9dddb --- /dev/null +++ b/push/src/test/java/top/trumeet/mipushframework/main/RegistrationStateStyleTest.kt @@ -0,0 +1,71 @@ +package top.trumeet.mipushframework.main + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import top.trumeet.mipush.provider.entities.RegisteredApplication.RegisteredType +import top.trumeet.mipush.provider.entities.RegisteredApplication.ServiceProbeState + +class RegistrationStateStyleTest { + @Test + fun registeredStateNeverReportsMissingServices() { + assertFalse( + RegistrationStateStyle.shouldShowMissingServices( + RegisteredType.Registered, + existServices = false, + ), + ) + } + + @Test + fun unregisteredStateReportsMissingServices() { + assertTrue( + RegistrationStateStyle.shouldShowMissingServices( + RegisteredType.Unregistered, + existServices = false, + ), + ) + } + + @Test + fun availableServicesSuppressDiagnostic() { + assertFalse( + RegistrationStateStyle.shouldShowMissingServices( + RegisteredType.NotRegistered, + existServices = true, + ), + ) + } + + @Test + fun unknownServiceProbeDoesNotReportMissingServices() { + assertFalse( + RegistrationStateStyle.shouldShowMissingServices( + RegisteredType.NotRegistered, + ServiceProbeState.UNKNOWN, + ), + ) + assertFalse( + RegistrationStateStyle.shouldShowMissingServices( + RegisteredType.Unregistered, + ServiceProbeState.UNKNOWN, + ), + ) + } + + @Test + fun legacyPositiveServiceFlagResolvesToPresent() { + val app = top.trumeet.mipush.provider.entities.RegisteredApplication().apply { + existServices = true + } + + assertEquals(ServiceProbeState.PRESENT, app.getServiceProbeState()) + assertFalse( + RegistrationStateStyle.shouldShowMissingServices( + RegisteredType.NotRegistered, + app.getServiceProbeState(), + ), + ) + } +} diff --git a/push/src/test/java/top/trumeet/mipushframework/utils/NotificationPermissionPolicyTest.java b/push/src/test/java/top/trumeet/mipushframework/utils/NotificationPermissionPolicyTest.java new file mode 100644 index 000000000..c9277578c --- /dev/null +++ b/push/src/test/java/top/trumeet/mipushframework/utils/NotificationPermissionPolicyTest.java @@ -0,0 +1,54 @@ +package top.trumeet.mipushframework.utils; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public class NotificationPermissionPolicyTest { + @Test + public void preAndroid13DoesNotRequireRuntimePermission() { + assertEquals(NotificationPermissionPolicy.Status.NOT_REQUIRED, + NotificationPermissionPolicy.evaluate(32, false, false, false)); + } + + @Test + public void grantedAlwaysWinsOnAndroid13AndLater() { + assertEquals(NotificationPermissionPolicy.Status.GRANTED, + NotificationPermissionPolicy.evaluate(34, true, true, false)); + } + + @Test + public void firstVisitIsRequestableExactlyOnce() { + NotificationPermissionPolicy.Status firstVisit = + NotificationPermissionPolicy.evaluate(34, false, false, false); + assertEquals(NotificationPermissionPolicy.Status.REQUESTABLE, firstVisit); + assertTrue(NotificationPermissionPolicy.shouldAutoRequest(firstVisit)); + + NotificationPermissionPolicy.Status denied = + NotificationPermissionPolicy.evaluate(34, false, true, true); + assertEquals(NotificationPermissionPolicy.Status.DENIED_CAN_ASK_AGAIN, denied); + assertFalse(NotificationPermissionPolicy.shouldAutoRequest(denied)); + } + + @Test + public void permanentlyDeniedRoutesToSettingsWithoutRepromptLoop() { + NotificationPermissionPolicy.Status blocked = + NotificationPermissionPolicy.evaluate(34, false, true, false); + assertEquals(NotificationPermissionPolicy.Status.BLOCKED, blocked); + assertFalse(NotificationPermissionPolicy.shouldAutoRequest(blocked)); + } + + @Test + public void settingsRoutePrefersPerAppNotificationSettingsWhenResolvable() { + assertEquals(NotificationPermissionPolicy.SettingsRoute.APP_NOTIFICATION_SETTINGS, + NotificationPermissionPolicy.chooseSettingsRoute(true)); + } + + @Test + public void settingsRouteFallsBackToApplicationDetailsWhenUnavailable() { + assertEquals(NotificationPermissionPolicy.SettingsRoute.APPLICATION_DETAILS_SETTINGS, + NotificationPermissionPolicy.chooseSettingsRoute(false)); + } +}