From 129b7cf6b494c59040680beb6d22c8df5d5b0146 Mon Sep 17 00:00:00 2001 From: SherlockChiang Date: Fri, 12 Dec 2025 18:42:17 +0800 Subject: [PATCH 01/64] Trying to fix Gson circular reference issue --- .../com/xiaomi/xmsf/utils/ConvertUtils.java | 42 ++++++++++++++++++- 1 file changed, 40 insertions(+), 2 deletions(-) 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) { From 487d393a45e9acd676aa583cce52fbe4c04bb4a6 Mon Sep 17 00:00:00 2001 From: SherlockChiang Date: Thu, 13 Aug 2026 19:31:57 +0800 Subject: [PATCH 02/64] feat: align notifications, Miuix UI, and idle behavior --- .gitignore | 1 + build.gradle | 13 +- common/build.gradle | 3 + .../common/utils/CustomConfiguration.java | 211 +++++++++++++++- .../common/utils/NotificationAlertUtils.java | 22 ++ .../common/widget/LinkAlertDialog.java | 9 +- .../utils/utils/CustomConfigurationTest.java | 162 +++++++++++- condom/build.gradle | 5 +- gradle.properties | 5 +- gradle/wrapper/gradle-wrapper.properties | 2 +- mipush_hook/build.gradle.kts | 34 ++- push/build.gradle | 34 +-- .../com/nihility/InternalMessengerTest.java | 87 ++++++- .../java/com/nihility/InternalMessenger.java | 108 +++++++- .../nihility/service/MessengerAbility.java | 5 + .../service/RegistrationRecorder.java | 9 +- .../service/XMPushServiceAbility.java | 45 +++- .../XMPushServiceListenerNotifier.java | 35 ++- .../service/MyMIPushNotificationHelper.java | 13 +- .../java/com/xiaomi/xmsf/FirstRegister.java | 32 ++- .../java/com/xiaomi/xmsf/RetryRegister.java | 21 +- .../push/control/PushControllerUtils.java | 189 ++++++++++++-- .../control/RegistrationRetryCoordinator.java | 117 +++++++++ .../NotificationChannelManager.java | 28 ++- .../notification/NotificationController.java | 234 +++++++++++++++--- .../service/receivers/KeepAliveReceiver.java | 36 ++- .../mipushframework/component/MiuixCompat.kt | 143 +++++++++++ .../mipushframework/component/SearchBar.kt | 156 ++++++++---- .../component/SettingsComponent.kt | 208 ++++++++++------ .../main/AdvancedSettingsPage.kt | 19 +- .../main/ApplicationInfoPage.kt | 44 ++-- .../trumeet/mipushframework/main/HelpPage.kt | 61 ++--- .../trumeet/mipushframework/main/MainPage.kt | 209 ++++++++++------ .../main/RecentEventListPage.kt | 31 ++- .../main/subpage/ApplicationListPage.kt | 14 +- .../main/subpage/BaseListPage.kt | 11 +- .../main/subpage/EventListPage.kt | 77 +++--- .../main/subpage/SettingsPage.kt | 54 ++-- .../wizard/RequestPermissionPage.kt | 43 ++-- .../main/java/top/trumeet/ui/theme/Color.kt | 9 +- .../main/java/top/trumeet/ui/theme/Theme.kt | 87 ++++--- .../main/java/top/trumeet/ui/theme/Type.kt | 47 ++-- push/src/main/res/values/strings.xml | 3 +- .../push/control/PushControllerUtilsTest.java | 81 ++++++ .../RegistrationRetryCoordinatorTest.java | 200 +++++++++++++++ .../receivers/KeepAliveReceiverTest.java | 31 +++ .../service/service/MessengerAbilityTest.java | 7 + .../XMPushServiceListenerNotifierTest.java | 40 +++ 48 files changed, 2422 insertions(+), 613 deletions(-) create mode 100644 common/src/main/java/top/trumeet/common/utils/NotificationAlertUtils.java create mode 100644 push/src/main/java/com/xiaomi/xmsf/push/control/RegistrationRetryCoordinator.java create mode 100644 push/src/main/java/top/trumeet/mipushframework/component/MiuixCompat.kt create mode 100644 push/src/test/java/com/xiaomi/xmsf/push/control/PushControllerUtilsTest.java create mode 100644 push/src/test/java/com/xiaomi/xmsf/push/control/RegistrationRetryCoordinatorTest.java create mode 100644 push/src/test/java/com/xiaomi/xmsf/push/service/receivers/KeepAliveReceiverTest.java diff --git a/.gitignore b/.gitignore index 97bc49b2d..a42df81e0 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ release/ .project org.eclipse.buildship.core.prefs .idea +.analysis/ diff --git a/build.gradle b/build.gradle index 48509179f..281d23491 100644 --- a/build.gradle +++ b/build.gradle @@ -1,7 +1,7 @@ // 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 { maven { url = uri("https://maven.aliyun.com/repository/central") } @@ -15,10 +15,11 @@ buildscript { 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' } } @@ -100,8 +101,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/utils/CustomConfiguration.java b/common/src/main/java/top/trumeet/common/utils/CustomConfiguration.java index 6a5c4b8d5..856235981 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,14 @@ 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.Map; import java.util.Set; @@ -31,12 +37,21 @@ 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 FOCUS_PARAM = "miui.focus.param"; + 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 +121,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) { @@ -126,6 +162,10 @@ public String notificationBigPicUri(String defaultValue) { return get(NOTIFICATION_BIGPIC_URI, defaultValue); } + public boolean notificationShowWhen(boolean defaultValue) { + return getBooleanValue(NOTIFICATION_SHOW_WHEN, defaultValue); + } + public boolean clearGroup(boolean defaultValue) { return get(CLEAR_GROUP, defaultValue); } @@ -137,6 +177,170 @@ public String focusParam(String defaultValue) { return get(FOCUS_PARAM, defaultValue); } + /** + * Parse the documented, public part of Xiaomi's focus-notification payload. + * Invalid or over-limit data is left out instead of being forwarded to SystemUI. + */ + public FocusNotificationPayload focusNotificationPayload() { + String parameter = focusParam(null); + if (!FocusNotificationPayload.isParameterWithinLimit(parameter)) { + parameter = null; + } + + List> pictureEntries = new ArrayList<>(); + for (Map.Entry entry : mExtra.entrySet()) { + String key = entry.getKey(); + String value = entry.getValue(); + if (key != null && key.startsWith(FOCUS_PICTURE_PREFIX) && isHttpsUrl(value)) { + pictureEntries.add(entry); + } + } + pictureEntries.sort(Comparator.comparing(Map.Entry::getKey, + CustomConfiguration::compareNaturally)); + + Map pictures = new LinkedHashMap<>(); + for (Map.Entry entry : pictureEntries) { + if (pictures.size() >= FOCUS_PICTURE_MAX_COUNT) { + break; + } + pictures.put(entry.getKey(), entry.getValue()); + } + return new FocusNotificationPayload(parameter, 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 isHttpsUrl(@Nullable String value) { + if (value == null || !value.regionMatches(true, 0, "https://", 0, 8)) { + return false; + } + int authorityStart = 8; + 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 CDN image URLs and can make + // an apparently HTTPS value 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 Map pictureUrls; + + private FocusNotificationPayload(@Nullable String parameter, + Map pictureUrls) { + this.parameter = parameter; + this.pictureUrls = Collections.unmodifiableMap( + new LinkedHashMap<>(pictureUrls)); + } + + @Nullable + public String parameter() { + return parameter; + } + + public Map pictureUrls() { + return pictureUrls; + } + + public boolean isUsable() { + return parameter != null; + } + + 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; + } + + 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 +352,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); } 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/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/CustomConfigurationTest.java b/common/src/test/java/test/top/trumeet/common/utils/utils/CustomConfigurationTest.java index 47d2aaaf4..82227063a 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,156 @@ public void textIcon() { assertEquals("qwe", custom.textIcon(null)); } -} \ No newline at end of file + + @Test + public void focusPayloadKeepsAtMostTenDistinctHttpsPictures() { + 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(CustomConfiguration.FOCUS_PICTURE_MAX_COUNT, + 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.pictureUrls().keySet())); + assertFalse(payload.pictureUrls().containsKey("miui.focus.pic_http")); + assertFalse(payload.pictureUrls().containsKey("miui.focus.pic_malformed")); + } + + @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 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 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/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..e82a7413f 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,18 +34,26 @@ 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") @@ -52,4 +62,4 @@ dependencies { 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/push/build.gradle b/push/build.gradle index 3355b2f18..02a03be43 100644 --- a/push/build.gradle +++ b/push/build.gradle @@ -1,6 +1,6 @@ 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' @@ -43,7 +43,7 @@ android { } testOptions { unitTests.all { - jvmArgs '-noverify' + jvmArgs '-noverify', '-Dnet.bytebuddy.experimental=true' } } @@ -68,10 +68,6 @@ android { } } - aspectjx { - enabled = false - } - greendao { // Must upgrade version code! schemaVersion 17 @@ -108,8 +104,11 @@ android { } } 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 @@ -121,9 +120,10 @@ android { buildFeatures { compose true + buildConfig true } composeOptions { - kotlinCompilerExtensionVersion "1.4.0" + // Kotlin 2.x uses the Compose compiler Gradle plugin. } namespace 'com.xiaomi.xmsf' } @@ -142,6 +142,7 @@ dependencies { implementation project(':condom') implementation project(':mipush_hook') compileOnly files(project(':mipush_hook').ext.mipushLib) + implementation 'org.aspectj:aspectjrt:1.9.22.1' // } // feature dependencies { @@ -179,10 +180,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 +200,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/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/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/xiaomi/push/service/MyMIPushNotificationHelper.java b/push/src/main/java/com/xiaomi/push/service/MyMIPushNotificationHelper.java index 122a8a4ec..5fc46010d 100644 --- a/push/src/main/java/com/xiaomi/push/service/MyMIPushNotificationHelper.java +++ b/push/src/main/java/com/xiaomi/push/service/MyMIPushNotificationHelper.java @@ -182,10 +182,15 @@ private static void wakeScreen(Context context, String sourcePackage) { 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; @@ -254,7 +259,7 @@ private static NotificationInfo getNotificationFor(Context context, XmPushAction addDebugAction(context, container, decryptedContent, metaInfo, packageName, notificationBuilder); notificationBuilder.setWhen(metaInfo.getMessageTs()); - notificationBuilder.setShowWhen(true); + notificationBuilder.setShowWhen(custom.notificationShowWhen(true)); String group = getGroupName(context, container); notificationBuilder.setGroup(group); 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/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/push/control/PushControllerUtils.java b/push/src/main/java/com/xiaomi/xmsf/push/control/PushControllerUtils.java index 577b2df65..dd0596b97 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 @@ -33,7 +33,9 @@ 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 +49,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 +136,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 +174,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,7 +207,13 @@ 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..."); @@ -128,30 +223,26 @@ public static void setServiceEnable(boolean enable, Context context) { } try { - Intent serviceIntent = new Intent(context, com.xiaomi.push.service.XMPushService.class); - serviceIntent.putExtra(PushServiceConstants.EXTRA_TIME_STAMP, System.currentTimeMillis()); + 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); - } + registerLiveReceiver(context); } 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. @@ -190,4 +281,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); + } + + 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); + } + } + } + + 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/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/notification/NotificationChannelManager.java b/push/src/main/java/com/xiaomi/xmsf/push/notification/NotificationChannelManager.java index 83d58cd4e..417ebdcc5 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; @@ -43,15 +47,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); } @@ -102,9 +117,10 @@ private static NotificationChannel createNotificationChannel(PushMetaInfo metaIn 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..f39764727 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,6 +2,8 @@ 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; @@ -12,8 +14,11 @@ import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.graphics.Color; +import android.graphics.drawable.Icon; +import android.net.Uri; import android.os.Build; import android.os.Bundle; +import android.provider.Settings; import android.service.notification.StatusBarNotification; import android.text.TextUtils; @@ -37,6 +42,20 @@ import com.xiaomi.xmsf.push.utils.IconConfigurations; import com.xiaomi.xmsf.utils.ColorUtil; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Callable; +import java.util.concurrent.CancellationException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Future; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + import top.trumeet.common.utils.CustomConfiguration; import top.trumeet.common.utils.ImgUtils; import top.trumeet.mipushframework.main.AdvancedSettingsPage; @@ -51,6 +70,13 @@ 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 String FOCUS_PROTOCOL_SETTING = "notification_focus_protocol"; + private static final String FOCUS_PARAM = "miui.focus.param"; + private static final String FOCUS_PICTURES = "miui.focus.pics"; + // 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. + private static final long FOCUS_DOWNLOAD_CALLER_BUDGET_MILLIS = 5_000L; public static final String CHANNEL_WARN = "warn"; @@ -76,7 +102,10 @@ 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, builder, metaInfo, false); } @RequiresApi(api = Build.VERSION_CODES.M) @@ -90,10 +119,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++; } } @@ -106,11 +141,11 @@ public static void publish(Context context, PushMetaInfo metaInfo, int notificat 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); + Notification notification = notify(context, notificationId, packageName, + notificationBuilder, metaInfo, true); updateSummaryNotification(context, metaInfo, packageName, notification.getGroup()); } @@ -130,7 +165,8 @@ public static String getExistsChannelId(Context context, PushMetaInfo metaInfo, private static Notification notify( Context context, int notificationId, String packageName, - NotificationCompat.Builder notificationBuilder, PushMetaInfo metaInfo) { + NotificationCompat.Builder notificationBuilder, PushMetaInfo metaInfo, + boolean includeFocusExtras) { // Make the behavior consistent with official MIUI Bundle extras = new Bundle(); extras.putString("target_package", packageName); @@ -139,34 +175,18 @@ 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); - } - - String subText = configuration.subText(null); - buildExtraSubText(context, packageName, notificationBuilder, subText); - - String focusParam = configuration.focusParam(null); - if (focusParam != null) { - Bundle focusBundle = new Bundle(); - focusBundle.putString("miui.focus.param", focusParam); - - 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); + if (includeFocusExtras) { + CustomConfiguration configuration = XMPushUtils.getConfiguration(metaInfo); + String iconUri = configuration.notificationLargeIconUri(null); + Bitmap largeIcon = getLargeIcon(context, metaInfo, iconUri); + if (largeIcon != null) { + notificationBuilder.setLargeIcon(largeIcon); } - notificationBuilder.addExtras(focusBundle); + + String subText = configuration.subText(null); + buildExtraSubText(context, packageName, notificationBuilder, subText); + + addFocusNotificationExtras(context, notificationBuilder, configuration); } notificationBuilder.setAutoCancel(true); @@ -176,6 +196,146 @@ private static Notification notify( return notification; } + 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, + NotificationCompat.Builder notificationBuilder, + CustomConfiguration configuration) { + CustomConfiguration.FocusNotificationPayload payload = + configuration.focusNotificationPayload(); + if (!payload.isUsable()) { + return; + } + + Bundle focusBundle = new Bundle(); + focusBundle.putString(FOCUS_PARAM, payload.parameter()); + if (isFocusProtocolEnabled(context)) { + for (Map.Entry picture : payload.pictureUrls().entrySet()) { + // Supported MIUI SystemUI needs both the URL and the native Icon. + focusBundle.putString(picture.getKey(), picture.getValue()); + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M + && !payload.pictureUrls().isEmpty()) { + focusBundle.putBundle(FOCUS_PICTURES, + FocusIconApi23.downloadPictures(context, payload.pictureUrls())); + } + } + notificationBuilder.addExtras(focusBundle); + } + + private static boolean isFocusProtocolEnabled(Context context) { + if (context == null || !"com.xiaomi.xmsf".equals(context.getPackageName())) { + return false; + } + int protocolVersion; + try { + protocolVersion = Settings.System.getInt(context.getContentResolver(), + FOCUS_PROTOCOL_SETTING, 0); + } catch (Throwable error) { + logger.w("Unable to read focus-notification protocol setting", error); + return false; + } + return CustomConfiguration.FocusNotificationPayload + .isSupportedProtocolVersion(protocolVersion); + } + + @RequiresApi(Build.VERSION_CODES.M) + private static final class FocusIconApi23 { + private static final ExecutorService IMAGE_EXECUTOR = createImageExecutor(); + + private FocusIconApi23() { + } + + 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; + } + + static Bundle downloadPictures( + Context context, Map pictureUrls) { + List> pictures = + new ArrayList<>(pictureUrls.entrySet()); + List> tasks = new ArrayList<>(pictures.size()); + for (Map.Entry picture : pictures) { + tasks.add(() -> downloadPicture(context, picture.getValue())); + } + + Bundle result = new Bundle(); + try { + List> futures = IMAGE_EXECUTOR.invokeAll( + tasks, FOCUS_DOWNLOAD_CALLER_BUDGET_MILLIS, TimeUnit.MILLISECONDS); + for (int i = 0; i < pictures.size(); i++) { + Icon icon = null; + Future future = futures.get(i); + if (!future.isCancelled()) { + try { + icon = future.get(); + } catch (ExecutionException | CancellationException error) { + logger.w("Unable to download focus-notification picture", error); + } + } + // Official XMSF retains the key with a null value on failure. + result.putParcelable(pictures.get(i).getKey(), icon); + } + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); + addNullPictures(result, pictures); + } catch (RuntimeException error) { + logger.w("Unable to schedule focus-notification pictures", error); + addNullPictures(result, pictures); + } + return result; + } + + private static void addNullPictures( + Bundle result, List> pictures) { + for (Map.Entry picture : pictures) { + result.putParcelable(picture.getKey(), null); + } + } + + @Nullable + private static Icon 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 = + MyNotificationIconHelper.getIconFromUrl(context, url, + CustomConfiguration.FOCUS_PICTURE_MAX_BYTES + 1); + if (result == null || result.bitmap == null + || !CustomConfiguration.FocusNotificationPayload + .isPictureSizeAllowed(result.downloadSize)) { + return null; + } + return Icon.createWithBitmap(result.bitmap); + } + } + @Nullable public static Bitmap getLargeIcon(Context context, PushMetaInfo metaInfo, String iconUri) { Bitmap largeIcon = Global.IconCache().getBitmap(context, iconUri, @@ -217,8 +377,10 @@ public static void cancel(Context context, XmPushActionContainer container, getNotificationTag(container), notificationId); if (clearGroup) { - getNotificationManagerEx().cancel(container.getPackageName(), - getNotificationTag(container), notificationGroup.hashCode()); + if (notificationGroup != null) { + getNotificationManagerEx().cancel(container.getPackageName(), + getNotificationTag(container), notificationGroup.hashCode()); + } return; } 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..a6cb7ba28 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,6 +3,7 @@ import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; +import android.os.SystemClock; import androidx.core.content.ContextCompat; @@ -10,6 +11,7 @@ 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; @@ -17,31 +19,55 @@ * @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; + } try { - long now = System.currentTimeMillis(); + long nowElapsedRealtime = SystemClock.elapsedRealtime(); - if ((now - lastActive) < (1000 * 60 * 2)) { + if (!shouldStart(lastActiveElapsedRealtime, nowElapsedRealtime)) { return; } - lastActive = now; + lastActiveElapsedRealtime = nowElapsedRealtime; + long now = System.currentTimeMillis(); 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); + if (!shouldUseForegroundStart(PushControllerUtils.isPushServiceRunning())) { + // The existing foreground service can receive a normal start command. Avoid + // asking Android to promote it again on every screen-on recovery check. + context.startService(localIntent); + } else { + ContextCompat.startForegroundService(context, localIntent); + } } catch (Exception localException) { MyLog.e(localException); } } + + static boolean shouldStart(long lastElapsedRealtime, long nowElapsedRealtime) { + return lastElapsedRealtime == 0L + || nowElapsedRealtime - lastElapsedRealtime >= MIN_START_INTERVAL_MS; + } + + static boolean shouldUseForegroundStart(boolean serviceRunning) { + return !serviceRunning; + } } 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..5a0049659 --- /dev/null +++ b/push/src/main/java/top/trumeet/mipushframework/component/MiuixCompat.kt @@ -0,0 +1,143 @@ +package top.trumeet.mipushframework.component + +import androidx.compose.foundation.layout.Arrangement +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.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +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.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 + +/** + * 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, +) { + NavigationBar( + modifier = modifier, + items = items, + selected = selected, + onClick = onClick, + defaultWindowInsetsPadding = true, + ) +} 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..d469cd6a8 100644 --- a/push/src/main/java/top/trumeet/mipushframework/main/AdvancedSettingsPage.kt +++ b/push/src/main/java/top/trumeet/mipushframework/main/AdvancedSettingsPage.kt @@ -8,12 +8,9 @@ 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 @@ -32,7 +29,10 @@ import com.xiaomi.xmsf.utils.ConfigCenter import top.trumeet.common.utils.Utils import top.trumeet.mipushframework.component.SettingsGroup import top.trumeet.mipushframework.component.SettingsItem +import top.trumeet.mipushframework.component.MiuixPageScaffold import top.trumeet.ui.theme.Theme +import top.yukonga.miuix.kmp.basic.Surface +import top.yukonga.miuix.kmp.theme.MiuixTheme class AdvancedSettingsPage : ComponentActivity() { @@ -40,23 +40,22 @@ class AdvancedSettingsPage : ComponentActivity() { 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() } 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..8782fc57f 100644 --- a/push/src/main/java/top/trumeet/mipushframework/main/ApplicationInfoPage.kt +++ b/push/src/main/java/top/trumeet/mipushframework/main/ApplicationInfoPage.kt @@ -22,14 +22,6 @@ import androidx.compose.foundation.layout.padding 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 @@ -57,7 +49,14 @@ 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.Icon +import top.yukonga.miuix.kmp.basic.Surface +import top.yukonga.miuix.kmp.basic.Text +import top.yukonga.miuix.kmp.theme.MiuixTheme class ApplicationInfoPage : ComponentActivity() { companion object { @@ -77,11 +76,9 @@ class ApplicationInfoPage : ComponentActivity() { init(getRegisteredApplication()!!) setContent { Theme { - window.navigationBarColor = MaterialTheme.colorScheme.surfaceColorAtElevation( - NavigationBarDefaults.Elevation - ).toArgb() + window.navigationBarColor = MiuixTheme.colorScheme.surfaceContainer.toArgb() + SettingsApp() } - SettingsApp() } } @@ -113,12 +110,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() } @@ -147,7 +145,7 @@ class ApplicationInfoPage : ComponentActivity() { modifier = Modifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically ) { - IconButton({ + MiuixActionIconButton(onClick = { RegistrationHelper( context, applicationInfo.packageName @@ -158,14 +156,14 @@ class ApplicationInfoPage : ComponentActivity() { Column(Modifier.weight(1f)) { Text( applicationInfo.appName, - style = MaterialTheme.typography.bodyMedium + style = MiuixTheme.textStyles.body2 ) Text( applicationInfo.packageName, - style = MaterialTheme.typography.bodySmall + style = MiuixTheme.textStyles.footnote1 ) } - IconButton({ + MiuixActionIconButton(onClick = { val uri = Uri.fromParts("package", applicationInfo.packageName, null) context.startActivity( Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) @@ -303,11 +301,11 @@ fun Tips(title: String, description: String) { ) Spacer(Modifier.width(10.dp)) Column { - Text(title, style = MaterialTheme.typography.bodyMedium) + Text(title, style = MiuixTheme.textStyles.body2) MarkdownView( description, - textSize = MaterialTheme.typography.bodySmall.fontSize.value, + textSize = MiuixTheme.textStyles.footnote1.fontSize.value, ) } } @@ -318,17 +316,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..91020d6ff 100644 --- a/push/src/main/java/top/trumeet/mipushframework/main/MainPage.kt +++ b/push/src/main/java/top/trumeet/mipushframework/main/MainPage.kt @@ -3,26 +3,21 @@ package top.trumeet.mipushframework.main import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent -import androidx.compose.foundation.layout.Arrangement 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.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.getValue import androidx.compose.runtime.mutableStateOf 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.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.toArgb -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 @@ -36,6 +31,8 @@ import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController 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 @@ -45,6 +42,8 @@ import top.trumeet.mipushframework.main.subpage.EventListPreview import top.trumeet.mipushframework.main.subpage.Settings import top.trumeet.mipushframework.main.subpage.SettingsPagePreview 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...") @@ -57,65 +56,140 @@ class MainPage : ComponentActivity() { mainPageUtils1.initOnCreate(applicationContext) { placeholder = it.toString() } setContent { Theme { - window.navigationBarColor = MaterialTheme.colorScheme.surfaceColorAtElevation( - NavigationBarDefaults.Elevation - ).toArgb() - } - Main(Screen.Apps.route.toString()) { - { - composable(Screen.Events.route.toString()) { - Column { - var query by rememberSaveable { mutableStateOf("") } - SearchBar(placeholder) { query = it } - EventList(query) + window.navigationBarColor = MiuixTheme.colorScheme.surfaceContainer.toArgb() + Main(Screen.Apps.route.toString()) { + { + composable(Screen.Events.route.toString()) { + Column { + var query by rememberSaveable { mutableStateOf("") } + SearchBar(placeholder) { query = it } + EventList(query) + } } - } - composable(Screen.Apps.route.toString()) { - Column { - var query by rememberSaveable { mutableStateOf("") } - SearchBar(placeholder) { query = it } - ApplicationList(query) + composable(Screen.Apps.route.toString()) { + Column { + var query by rememberSaveable { mutableStateOf("") } + SearchBar(placeholder) { query = it } + ApplicationList(query) + } } + composable(Screen.Settings.route.toString()) { Settings() } } - composable(Screen.Settings.route.toString()) { Settings() } } } } } } -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) { 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 selected = items.indexOfFirst { it.route.toString() == currentRoute }.coerceAtLeast(0) + + MiuixBottomNavigation( + 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 + } + }, + ) } @Composable @@ -125,23 +199,18 @@ private fun Main( ) { val navController = rememberNavController() - Theme { - Column( - Modifier - .statusBarsPadding() - .navigationBarsPadding() - .fillMaxSize(), - verticalArrangement = Arrangement.SpaceBetween - ) { - Column(Modifier.weight(1f)) { - NavHost( - navController = navController, - startDestination = startDestination, - builder = navContent() - ) - } - BottomNavigationBar(navController) - } + MiuixPageScaffold( + modifier = Modifier.fillMaxSize(), + bottomBar = { BottomNavigationBar(navController) }, + ) { paddingValues -> + NavHost( + modifier = Modifier + .padding(paddingValues) + .consumeWindowInsets(paddingValues), + navController = navController, + startDestination = startDestination, + builder = navContent() + ) } } @@ -217,4 +286,4 @@ private fun MainDialogPreview() { composable(Screen.Settings.route.toString()) { } } } -} \ No newline at end of file +} 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..7a96921e0 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,8 +15,10 @@ 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() { @@ -29,18 +28,18 @@ class RecentEventListPage : ComponentActivity() { 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/subpage/ApplicationListPage.kt b/push/src/main/java/top/trumeet/mipushframework/main/subpage/ApplicationListPage.kt index b8c62c646..ad1221cd0 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 @@ -11,9 +11,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.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 @@ -43,6 +40,9 @@ 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.Text +import top.yukonga.miuix.kmp.theme.MiuixTheme data class AppInfoForDisplay( val registrationState: Pair, @@ -173,7 +173,7 @@ private fun LastReceive(item: RegisteredApplication) { val info = g_itemsInfo[item.packageName]!! Text( info.lastReceiveTime, - style = MaterialTheme.typography.bodyLarge, + style = MiuixTheme.textStyles.body1, ) } @@ -183,12 +183,12 @@ private fun AppInfo(item: RegisteredApplication) { 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 +277,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/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..03b1c99b3 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 @@ -36,7 +32,6 @@ 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 +43,12 @@ 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.MiuixActionButton +import top.trumeet.mipushframework.component.MiuixDialog import top.trumeet.mipushframework.component.RefreshableLazyColumn import top.trumeet.mipushframework.component.TextView +import top.yukonga.miuix.kmp.basic.Text +import top.yukonga.miuix.kmp.theme.MiuixTheme import java.text.SimpleDateFormat import java.util.Date @@ -130,14 +129,34 @@ private fun EventDetailsDialog( val screenHeight = LocalConfiguration.current.screenHeightDp.dp val targetHeight = screenHeight * 0.9f - AlertDialog( - onDismiss, - { + val show = remember(clickedEvent.id) { mutableStateOf(true) } + 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.End, + verticalAlignment = Alignment.CenterVertically, + ) { + MiuixActionButton(onClick = { + EventListPageUtils.startManagePermissions( + context, + clickedEvent.packageName + ) + }) { Text(stringResource(R.string.action_app_info)) } + } + TextView(json) Row( Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween ) { - TextButton({ + MiuixActionButton(onClick = { json = EventListPageUtils.getContent( clickedEvent.event, @@ -145,11 +164,11 @@ private fun EventDetailsDialog( ) }) { Text(stringResource(R.string.action_configurate)) } - TextButton({ + MiuixActionButton(onClick = { EventListPageUtils.copyToClipboard(context, json) }) { Text(stringResource(android.R.string.copy)) } - TextButton({ + MiuixActionButton(onClick = { EventListPageUtils.mockMessage( RegSecUtils.getContainerWithRegSec( clickedEvent.event @@ -157,30 +176,8 @@ private fun EventDetailsDialog( ) }) { Text(stringResource(R.string.action_notify)) } } - }, - 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 val g_items = mutableStateListOf() @@ -257,28 +254,28 @@ private fun EventItem(item: EventInfoForDisplay, onClick: (EventInfoForDisplay) @Composable private fun ConfigOptions(item: EventInfoForDisplay) { if (item.configOptions.isNotEmpty()) { - Text(item.configOptions.toString(), style = MaterialTheme.typography.bodySmall) + Text(item.configOptions.toString(), style = MiuixTheme.textStyles.footnote1) Spacer(Modifier.width(5.dp)) } } @Composable private fun ChannelInfo(item: EventInfoForDisplay) { - Text(item.channel, style = MaterialTheme.typography.bodySmall) + Text(item.channel, style = MiuixTheme.textStyles.footnote1) } @Composable private fun EventReceiveDate(item: EventInfoForDisplay) { val format = receiveDateFormat - Text(format.format(item.receiveDate), style = MaterialTheme.typography.bodySmall) + Text(format.format(item.receiveDate), style = MiuixTheme.textStyles.footnote1) } @Composable private fun EventTitle(item: EventInfoForDisplay) { Text( item.title, - style = MaterialTheme.typography.bodyLarge, + style = MiuixTheme.textStyles.body1, ) } @@ -286,7 +283,7 @@ private fun EventTitle(item: EventInfoForDisplay) { private fun EventContent(item: EventInfoForDisplay) { Text( item.content, - style = MaterialTheme.typography.bodyMedium, + style = MiuixTheme.textStyles.body2, ) } 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..88d86e036 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 @@ -10,12 +10,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize 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.runtime.DisposableEffect import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -33,21 +28,24 @@ 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.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() - } + Surface( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + color = MiuixTheme.colorScheme.background + ) { + SettingsScreen() } } @@ -79,22 +77,20 @@ private fun ServiceConfigurationBlock() { } @Composable -@OptIn(ExperimentalMaterial3Api::class) 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 +98,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,10 +111,10 @@ private fun SetXMPPServer(context: Context) { text = "" }, content = { - TextField( + MiuixInput( value = text, onValueChange = { text = it }, - placeholder = { Text(SettingUtils.getXMPPServerHint()) }, + label = SettingUtils.getXMPPServerHint(), singleLine = true ) }) 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..f1c7cf443 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,63 @@ package top.trumeet.ui.theme -import android.app.Activity import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.darkColorScheme -import androidx.compose.material3.dynamicDarkColorScheme -import androidx.compose.material3.dynamicLightColorScheme -import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.core.content.ContextCompat +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, ) @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) - } - - darkTheme -> DarkColorScheme - else -> LightColorScheme + val context = LocalContext.current + val dynamicPrimaryArgb = if (dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + Color( + ContextCompat.getColor( + context, + if (darkTheme) android.R.color.system_accent1_200 + else android.R.color.system_accent1_600 + ) + ).value + } else { + null + } + val baseColors = if (darkTheme) DarkColorScheme else LightColorScheme + val colors = remember(darkTheme, dynamicPrimaryArgb) { + dynamicPrimaryArgb?.let { argb -> + val primary = Color(argb) + baseColors.copy( + primary = primary, + primaryVariant = primary, + onTertiaryContainer = primary, + ) + } ?: baseColors } - MaterialTheme( - colorScheme = colorScheme, - typography = Typography, - content = content + MiuixTheme( + colors = colors, + textStyles = AppTextStyles, + content = content, ) -} \ No newline at end of file +} 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/values/strings.xml b/push/src/main/res/values/strings.xml index c68763172..77cfdb461 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 @@ -285,4 +286,4 @@ This could be the application doing a reverse registration, or the registration Try to force register all applications - \ No newline at end of file + 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/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/service/receivers/KeepAliveReceiverTest.java b/push/src/test/java/com/xiaomi/xmsf/push/service/receivers/KeepAliveReceiverTest.java new file mode 100644 index 000000000..437d76ba9 --- /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 runningServiceDoesNotRequireAnotherForegroundStart() { + assertFalse(KeepAliveReceiver.shouldUseForegroundStart(true)); + assertTrue(KeepAliveReceiver.shouldUseForegroundStart(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(); From b2abfadddfd8c2bffb796338ec4142062a3a4b6d Mon Sep 17 00:00:00 2001 From: SherlockChiang Date: Sun, 16 Aug 2026 07:31:41 +0800 Subject: [PATCH 03/64] feat: expand HyperOS notifications and idle efficiency --- .github/workflows/test_ci.yml | 48 ++-- .gitignore | 1 + .../java/top/trumeet/common/Constants.java | 8 - .../common/push/PushServiceAccessibility.java | 4 +- .../common/utils/AlarmSchedulePolicy.java | 19 ++ .../common/utils/CustomConfiguration.java | 141 ++++++++++- .../utils/utils/AlarmSchedulePolicyTest.java | 44 ++++ .../utils/utils/CustomConfigurationTest.java | 76 +++++- mipush_hook/build.gradle.kts | 1 + .../timers/AlarmManagerTimerAspect.java | 123 ++++++++++ .../AlarmManagerTimerSchedulePolicy.java | 66 ++++++ .../AlarmManagerTimerSchedulePolicyTest.java | 50 ++++ push/build.gradle | 129 +++++++--- push/src/main/AndroidManifest.xml | 42 +++- .../nihility/service/ForegroundHelper.java | 70 +++--- .../main/java/com/nihility/utils/Hooker.java | 1 - .../NotificationsRevivalForSelfUpdated.kt | 9 +- .../service/MyMIPushNotificationHelper.java | 98 ++++++-- .../service/MyNotificationIconHelper.java | 224 ++++++++++-------- .../com/xiaomi/xmsf/MiPushFrameworkApp.java | 58 +++-- .../java/com/xiaomi/xmsf/SettingUtils.java | 22 +- .../push/control/PushControllerUtils.java | 26 +- .../push/control/PushServiceDispatcher.java | 139 +++++++++++ .../push/control/PushServiceStartPolicy.java | 34 +++ .../xmsf/push/control/StartupWorkPolicy.java | 23 ++ .../notification/NotificationController.java | 190 +++++++++++++-- .../notification/NotificationManagerEx.kt | 33 ++- .../push/service/MiuiPushActivateService.java | 11 +- .../xmsf/push/service/XMPushService.java | 10 +- .../push/service/receivers/BootReceiver.java | 9 +- .../service/receivers/KeepAliveReceiver.java | 24 +- .../service/receivers/MiPushPingReceiver.java | 18 +- .../receivers/NetworkStatusReceiver.java | 19 +- .../receivers/PkgUninstallReceiver.java | 5 +- .../com/xiaomi/xmsf/utils/ConfigCenter.java | 23 +- .../java/com/xiaomi/xmsf/utils/LogUtils.java | 4 +- .../mipushframework/MainPageUtils.java | 24 +- .../mipushframework/component/MiuixCompat.kt | 32 ++- .../main/AdvancedSettingsPage.kt | 42 +++- .../main/AppConfigurationUtils.java | 4 +- .../trumeet/mipushframework/main/MainPage.kt | 166 +++++++++---- .../main/subpage/SettingsPage.kt | 122 ++++++++-- .../NotificationPermissionController.java | 59 +++++ .../utils/NotificationPermissionPolicy.java | 45 ++++ .../utils/PermissionUtils.java | 7 +- .../main/java/top/trumeet/ui/theme/Theme.kt | 83 +++++-- push/src/main/res/values-zh/strings.xml | 84 +++---- push/src/main/res/values/strings.xml | 15 +- push/src/qa/AndroidManifest.xml | 101 ++++++++ .../service/NotificationExecutorTest.java | 48 ++++ .../timers/AlarmManagerTimerSmokeTest.java | 53 +++++ .../xmsf/ManifestComponentContractTest.java | 140 +++++++++++ .../xmsf/NormalVariantContractTest.java | 26 ++ .../xiaomi/xmsf/QaVariantContractTest.java | 26 ++ .../com/xiaomi/xmsf/ReceiverDisabledTest.java | 38 +++ .../PushServiceDispatcherConfigTest.java | 26 ++ .../control/PushServiceStartPolicyTest.java | 47 ++++ .../push/control/StartupWorkPolicyTest.java | 24 ++ ...onfigCenterStartForegroundServiceTest.java | 34 +++ .../NotificationPermissionPolicyTest.java | 42 ++++ 60 files changed, 2561 insertions(+), 529 deletions(-) create mode 100644 common/src/main/java/top/trumeet/common/utils/AlarmSchedulePolicy.java create mode 100644 common/src/test/java/test/top/trumeet/common/utils/utils/AlarmSchedulePolicyTest.java create mode 100644 mipush_hook/src/main/java/com/xiaomi/push/service/timers/AlarmManagerTimerAspect.java create mode 100644 mipush_hook/src/main/java/com/xiaomi/push/service/timers/AlarmManagerTimerSchedulePolicy.java create mode 100644 mipush_hook/src/test/java/com/xiaomi/push/service/timers/AlarmManagerTimerSchedulePolicyTest.java create mode 100644 push/src/main/java/com/xiaomi/xmsf/push/control/PushServiceDispatcher.java create mode 100644 push/src/main/java/com/xiaomi/xmsf/push/control/PushServiceStartPolicy.java create mode 100644 push/src/main/java/com/xiaomi/xmsf/push/control/StartupWorkPolicy.java create mode 100644 push/src/main/java/top/trumeet/mipushframework/utils/NotificationPermissionController.java create mode 100644 push/src/main/java/top/trumeet/mipushframework/utils/NotificationPermissionPolicy.java create mode 100644 push/src/qa/AndroidManifest.xml create mode 100644 push/src/test/java/com/xiaomi/push/service/NotificationExecutorTest.java create mode 100644 push/src/test/java/com/xiaomi/push/service/timers/AlarmManagerTimerSmokeTest.java create mode 100644 push/src/test/java/com/xiaomi/xmsf/ManifestComponentContractTest.java create mode 100644 push/src/test/java/com/xiaomi/xmsf/NormalVariantContractTest.java create mode 100644 push/src/test/java/com/xiaomi/xmsf/QaVariantContractTest.java create mode 100644 push/src/test/java/com/xiaomi/xmsf/ReceiverDisabledTest.java create mode 100644 push/src/test/java/com/xiaomi/xmsf/push/control/PushServiceDispatcherConfigTest.java create mode 100644 push/src/test/java/com/xiaomi/xmsf/push/control/PushServiceStartPolicyTest.java create mode 100644 push/src/test/java/com/xiaomi/xmsf/push/control/StartupWorkPolicyTest.java create mode 100644 push/src/test/java/com/xiaomi/xmsf/utils/ConfigCenterStartForegroundServiceTest.java create mode 100644 push/src/test/java/top/trumeet/mipushframework/utils/NotificationPermissionPolicyTest.java diff --git a/.github/workflows/test_ci.yml b/.github/workflows/test_ci.yml index 6d3ca45f6..57d986797 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 HAS_SIGNING_KEY=true >> $GITHUB_ENV echo KEYSTORE_PASSWORD='${{ secrets.KEYSTORE_PASSWORD }}' >> local.properties echo KEYSTORE_ALIAS='${{ secrets.KEYSTORE_ALIAS }}' >> local.properties echo KEY_PASSWORD='${{ secrets.KEY_PASSWORD }}' >> local.properties echo KEY_LOCATE='../release.keystore' >> local.properties echo ${{ secrets.SIGNING_KEY }} | base64 --decode > release.keystore + else + echo HAS_SIGNING_KEY=false >> $GITHUB_ENV fi - name: Git describe @@ -40,52 +43,65 @@ 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 assembleQaDebug assembleQaRelease 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: Collect artifact name run: | - for variant in vc105 normal; do + for variant in qa 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 + if compgen -G "push/build/outputs/apk/$variant/$build_type/*.apk" > /dev/null; then + artifact_name=$(basename -s .apk push/build/outputs/apk/$variant/$build_type/*.apk) + echo "${variant}_${build_type}_artifact=$artifact_name" >> $GITHUB_ENV + fi done 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 + - name: Upload Release For QA + if: env.qa_release_artifact != '' uses: actions/upload-artifact@v4.6.0 with: - name: ${{ env.vc105_release_artifact }} - path: push/build/outputs/apk/vc105/release/*.apk + name: ${{ env.qa_release_artifact }} + path: push/build/outputs/apk/qa/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 + - name: Upload Debug For QA + if: env.qa_debug_artifact != '' uses: actions/upload-artifact@v4.6.0 with: - name: ${{ env.vc105_debug_artifact }} - path: push/build/outputs/apk/vc105/debug/*.apk + name: ${{ env.qa_debug_artifact }} + path: push/build/outputs/apk/qa/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/*/*/*.apk" > /dev/null; then + artifact_name=(push/build/outputs/apk/*/*/*.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' && steps.gvn.outputs.version != '' uses: softprops/action-gh-release@v2 with: name: ${{ steps.gvn.outputs.version }} diff --git a/.gitignore b/.gitignore index a42df81e0..00a4dcdff 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ *.iml .gradle +.kotlin/ /local.properties .DS_Store /build 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/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..3e399560f --- /dev/null +++ b/common/src/main/java/top/trumeet/common/utils/AlarmSchedulePolicy.java @@ -0,0 +1,19 @@ +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) { + 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 856235981..8273219f7 100644 --- a/common/src/main/java/top/trumeet/common/utils/CustomConfiguration.java +++ b/common/src/main/java/top/trumeet/common/utils/CustomConfiguration.java @@ -10,6 +10,7 @@ import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Set; @@ -45,6 +46,22 @@ private static String Config(String name) { 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_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"; private static final String FOCUS_PICTURE_PREFIX = "miui.focus.pic_"; @@ -166,6 +183,70 @@ public boolean notificationShowWhen(boolean defaultValue) { return getBooleanValue(NOTIFICATION_SHOW_WHEN, defaultValue); } + public String notificationStyleType(String defaultValue) { + return get(NOTIFICATION_STYLE_TYPE, defaultValue); + } + + 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 notificationColorfulButtonBackgroundImageUri(String defaultValue) { + return get(NOTIFICATION_COLORFUL_BUTTON_BG_IMAGE_URI, defaultValue); + } + + 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); } @@ -191,7 +272,8 @@ public FocusNotificationPayload focusNotificationPayload() { for (Map.Entry entry : mExtra.entrySet()) { String key = entry.getKey(); String value = entry.getValue(); - if (key != null && key.startsWith(FOCUS_PICTURE_PREFIX) && isHttpsUrl(value)) { + if (key != null && key.startsWith(FOCUS_PICTURE_PREFIX) + && isSupportedPictureValue(value)) { pictureEntries.add(entry); } } @@ -200,9 +282,8 @@ public FocusNotificationPayload focusNotificationPayload() { Map pictures = new LinkedHashMap<>(); for (Map.Entry entry : pictureEntries) { - if (pictures.size() >= FOCUS_PICTURE_MAX_COUNT) { - break; - } + // 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, pictures); @@ -268,11 +349,23 @@ private static int compareNaturally(String left, String right) { return Integer.compare(left.length() - leftIndex, right.length() - rightIndex); } - private static boolean isHttpsUrl(@Nullable String value) { - if (value == null || !value.regionMatches(true, 0, "https://", 0, 8)) { + 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 authorityStart = 8; int authorityEnd = value.length(); for (char delimiter : new char[]{'/', '?', '#'}) { int index = value.indexOf(delimiter, authorityStart); @@ -284,8 +377,8 @@ private static boolean isHttpsUrl(@Nullable String value) { return false; } String authority = value.substring(authorityStart, authorityEnd); - // User-info and whitespace are unnecessary for CDN image URLs and can make - // an apparently HTTPS value resolve somewhere unexpected. + // 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); } @@ -318,8 +411,23 @@ public Map pictureUrls() { return pictureUrls; } + /** URLs selected for native Icon downloads; the URL payload remains complete. */ + public Map downloadPictureUrls() { + if (pictureUrls.size() <= FOCUS_PICTURE_MAX_COUNT) { + return pictureUrls; + } + Map result = new LinkedHashMap<>(); + for (Map.Entry entry : pictureUrls.entrySet()) { + if (result.size() >= FOCUS_PICTURE_MAX_COUNT) { + break; + } + result.put(entry.getKey(), entry.getValue()); + } + return Collections.unmodifiableMap(result); + } + public boolean isUsable() { - return parameter != null; + return parameter != null || !pictureUrls.isEmpty(); } public static boolean isSupportedProtocolVersion(int version) { @@ -368,6 +476,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/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..8ae267e4e --- /dev/null +++ b/common/src/test/java/test/top/trumeet/common/utils/utils/AlarmSchedulePolicyTest.java @@ -0,0 +1,44 @@ +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 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 82227063a..93b5e7cf1 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 @@ -28,7 +28,7 @@ public void textIcon() { } @Test - public void focusPayloadKeepsAtMostTenDistinctHttpsPictures() { + 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"); @@ -42,7 +42,7 @@ public void focusPayloadKeepsAtMostTenDistinctHttpsPictures() { assertTrue(payload.isUsable()); assertEquals("{\"ticker\":\"parcel\"}", payload.parameter()); - assertEquals(CustomConfiguration.FOCUS_PICTURE_MAX_COUNT, + assertEquals(12, payload.pictureUrls().size()); assertEquals("https://example.com/0.png", payload.pictureUrls().get("miui.focus.pic_0")); @@ -51,9 +51,61 @@ public void focusPayloadKeepsAtMostTenDistinctHttpsPictures() { "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.pictureUrls().keySet())); + new ArrayList<>(payload.downloadPictureUrls().keySet())); assertFalse(payload.pictureUrls().containsKey("miui.focus.pic_http")); assertFalse(payload.pictureUrls().containsKey("miui.focus.pic_malformed")); + assertTrue(payload.pictureUrls().containsKey("miui.focus.pic_10")); + assertTrue(payload.pictureUrls().containsKey("miui.focus.pic_11")); + } + + @Test + public void focusPayloadRejectsUrlsWithUserInfoEmptyHostAndWhitespace() { + 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(1, payload.pictureUrls().size()); + assertTrue(payload.pictureUrls().containsKey("miui.focus.pic_6")); + assertEquals("https://valid.example.com/image.png", payload.pictureUrls().get("miui.focus.pic_6")); + assertFalse(payload.pictureUrls().containsKey("miui.focus.pic_1")); + assertFalse(payload.pictureUrls().containsKey("miui.focus.pic_2")); + assertFalse(payload.pictureUrls().containsKey("miui.focus.pic_3")); + assertFalse(payload.pictureUrls().containsKey("miui.focus.pic_4")); + assertFalse(payload.pictureUrls().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 @@ -155,6 +207,24 @@ public void notificationShowWhenParsesValueInsteadOfPresence() { assertTrue(custom.notificationShowWhen(true)); } + @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 resourceSoundRequiresSoundBitAndMatchingPackage() { String packageName = "com.example.app"; diff --git a/mipush_hook/build.gradle.kts b/mipush_hook/build.gradle.kts index e82a7413f..2edeef0eb 100644 --- a/mipush_hook/build.gradle.kts +++ b/mipush_hook/build.gradle.kts @@ -59,6 +59,7 @@ dependencies { 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") 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..f365defb4 --- /dev/null +++ b/mipush_hook/src/main/java/com/xiaomi/push/service/timers/AlarmManagerTimerAspect.java @@ -0,0 +1,123 @@ +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 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(); + } + + AlarmManagerTimerSchedulePolicy.Schedule schedule = + AlarmManagerTimerSchedulePolicy.forWallClockDeadline( + Build.VERSION.SDK_INT, canScheduleExact, 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..407ffd5c3 --- /dev/null +++ b/mipush_hook/src/main/java/com/xiaomi/push/service/timers/AlarmManagerTimerSchedulePolicy.java @@ -0,0 +1,66 @@ +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) { + AlarmScheduleType scheduleType = + AlarmSchedulePolicy.determineScheduleType(sdkInt, canScheduleExactAlarms); + 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..027e71f2d --- /dev/null +++ b/mipush_hook/src/test/java/com/xiaomi/push/service/timers/AlarmManagerTimerSchedulePolicyTest.java @@ -0,0 +1,50 @@ +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 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 02a03be43..70f382624 100644 --- a/push/build.gradle +++ b/push/build.gradle @@ -3,7 +3,6 @@ apply plugin: 'kotlin-android' apply plugin: 'org.jetbrains.kotlin.plugin.compose' apply plugin: 'org.greenrobot.greendao' - android { compileSdkVersion rootProject.ext.compileSdkVersion @@ -28,6 +27,59 @@ android { } buildConfigField "String", "GIT_TAG", "\"" + rootProject.ext.gitTag + "\"" + buildConfigField "boolean", "QA_BUILD", "false" + manifestPlaceholders = [ + mipushReceivePermission: "com.xiaomi.xmsf.permission.MIPUSH_RECEIVE" + ] + } + + signingConfigs { + debug { + v1SigningEnabled true + v2SigningEnabled true + enableV3Signing = true + enableV4Signing = true + } + qa { + initWith(signingConfigs.debug) + } + nihility { + 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()) + 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,9 +87,8 @@ android { signingConfig signingConfigs.debug } release { - signingConfig signingConfigs.debug + debuggable false minifyEnabled false - //shrinkResources true proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' } } @@ -51,13 +102,33 @@ android { productFlavors { normal { dimension "version" + targetSdkVersion 34 + versionCode 1003003001 + signingConfig signingConfigs.nihility + } + qa { + dimension "version" + // Install beside a preloaded XMSF when its signing key is unavailable. + // This variant is intentionally limited to local UI/notification/perf QA. + applicationIdSuffix ".qa" + versionNameSuffix "-qa" + buildConfigField "boolean", "QA_BUILD", "true" + targetSdkVersion 34 + manifestPlaceholders = [ + mipushReceivePermission: "com.xiaomi.xmsf.qa.permission.MIPUSH_RECEIVE" + ] + signingConfig signingConfigs.qa } vc105 { dimension "version" versionCode = 105 + targetSdkVersion rootProject.ext.targetSdkVersion + signingConfig signingConfigs.nihility } prebuilt { dimension "version" + targetSdkVersion rootProject.ext.targetSdkVersion + signingConfig signingConfigs.nihility } } androidComponents { @@ -75,34 +146,6 @@ 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_11 targetCompatibility JavaVersion.VERSION_11 @@ -112,8 +155,6 @@ android { } 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 } @@ -122,6 +163,14 @@ android { 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 { // Kotlin 2.x uses the Compose compiler Gradle plugin. } @@ -136,6 +185,20 @@ android.applicationVariants.all { variant -> variant.getProcessJavaResourcesProvider().get().dependsOn(taskName) } +gradle.taskGraph.whenReady { taskGraph -> + boolean requiresNihilityKey = 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 (requiresNihilityKey) { + def nihilityStore = android.signingConfigs.nihility.storeFile + if (nihilityStore == null || !nihilityStore.exists()) { + throw new GradleException("Official Nihility signing key (.yuuta.jks / KEY_LOCATE) is missing. Release build cannot proceed.") + } + } +} + dependencies { // modules { implementation project(':common') diff --git a/push/src/main/AndroidManifest.xml b/push/src/main/AndroidManifest.xml index 3e2cc4eb7..94239fa3c 100644 --- a/push/src/main/AndroidManifest.xml +++ b/push/src/main/AndroidManifest.xml @@ -3,11 +3,14 @@ xmlns:tools="http://schemas.android.com/tools"> + + + - + @@ -65,7 +68,8 @@ --> + android:icon="@mipmap/ic_launcher" + android:exported="true"> @@ -89,7 +93,7 @@ - + - + @@ -119,7 +123,7 @@ + android:exported="false"> @@ -141,7 +145,7 @@ - + @@ -149,18 +153,26 @@ - + - + + + + + android:exported="false" /> + android:exported="true" + android:foregroundServiceType="specialUse"> + + @@ -198,6 +215,7 @@ 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/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/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/service/MyMIPushNotificationHelper.java b/push/src/main/java/com/xiaomi/push/service/MyMIPushNotificationHelper.java index 5fc46010d..fe487b25e 100644 --- a/push/src/main/java/com/xiaomi/push/service/MyMIPushNotificationHelper.java +++ b/push/src/main/java/com/xiaomi/push/service/MyMIPushNotificationHelper.java @@ -73,6 +73,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(); @@ -106,10 +107,33 @@ public class MyMIPushNotificationHelper { 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"; - 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); + private static final java.util.concurrent.ThreadPoolExecutor executorService = createNotificationExecutor(); + + 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<>(32), + r -> { + Thread t = new Thread(r, "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 @@ -126,23 +150,24 @@ public static void notifyPushMessage(Context context, byte[] decryptedContent) { } private static void handleNotificationByConfigurations(Context context, byte[] decryptedContent, String packageName, XmPushActionContainer container) { + Context appContext = context.getApplicationContext() != null ? context.getApplicationContext() : context; try { Set operations = Configurations.getInstance().handle(packageName, container); if (operations.contains(PackageConfig.OPERATION_WAKE)) { - wakeScreen(context, packageName); + wakeScreen(appContext, packageName); } if (!operations.contains(PackageConfig.OPERATION_IGNORE)) { executorService.execute(() -> { try { - doNotifyPushMessage(context, container, decryptedContent); + doNotifyPushMessage(appContext, container, decryptedContent); } catch (Exception e) { logger.e(e.getLocalizedMessage(), e); } }); } if (operations.contains(PackageConfig.OPERATION_OPEN)) { - MyPushMessageHandler.startService(context, container, decryptedContent); + MyPushMessageHandler.startService(appContext, container, decryptedContent); } } catch (Exception e) { logger.e(e.getLocalizedMessage(), e); @@ -172,6 +197,9 @@ 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) { + return; + } PowerManager.WakeLock fullWakeLock = powerManager.newWakeLock(( PowerManager.SCREEN_BRIGHT_WAKE_LOCK | PowerManager.FULL_WAKE_LOCK | @@ -290,12 +318,11 @@ public NotificationInfo(int notificationId, NotificationCompat.Builder notificat 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; } @@ -304,8 +331,17 @@ private static Context getPackageContext(Context context, String packageName) { private static NotificationCompat.Builder normalStyleNotificationBuilder(Context context, PushMetaInfo metaInfo) { String title = metaInfo.getTitle(); String description = metaInfo.getDescription(); + CustomConfiguration configuration = XMPushUtils.getConfiguration(metaInfo); + String styleType = configuration.notificationStyleType(null); Bitmap bigPic = getBigPic(context, metaInfo); + if ("3".equals(styleType)) { + bigPic = getBitmapFromUri(context, + configuration.notificationBannerImageUri(null), 1 * MiB); + } else if ("4".equals(styleType)) { + bigPic = getBitmapFromUri(context, + configuration.notificationColorfulButtonBackgroundImageUri(null), 1 * MiB); + } NotificationCompat.Builder notificationBuilder = new NotificationCompat.Builder(context); if (bigPic != null) { @@ -313,13 +349,24 @@ private static NotificationCompat.Builder normalStyleNotificationBuilder(Context style.bigPicture(bigPic); style.setBigContentTitle(title); notificationBuilder.setStyle(style); - } else if (description.length() > NOTIFICATION_BIG_STYLE_MIN_LEN) { + } else if ("1".equals(styleType) + || description.length() > NOTIFICATION_BIG_STYLE_MIN_LEN) { NotificationCompat.BigTextStyle style = new NotificationCompat.BigTextStyle(); style.bigText(description); style.setBigContentTitle(title); notificationBuilder.setStyle(style); } + if ("4".equals(styleType)) { + String background = configuration.notificationColorfulButtonBackgroundColor(null); + if (background != null) { + try { + notificationBuilder.setColor(Color.parseColor(background)); + } catch (IllegalArgumentException ignored) { + } + } + } + String[] titleAndDesp = determineTitleAndDespByDIP(context, metaInfo); notificationBuilder.setContentTitle(titleAndDesp[0]); notificationBuilder.setContentText(titleAndDesp[1]); @@ -477,7 +524,8 @@ private static void carryPendingIntentForTemporarilyWhitelisted(Context xmPushSe 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 PendingIntent pi = PendingIntent.getService(xmPushService, 0, targetIntent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); localBuilder.getExtras().putParcelable("mipush.target", pi); } @@ -529,7 +577,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)); } } @@ -546,7 +596,8 @@ private static PendingIntent openActivityPendingIntent(Context paramContext, XmP 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; } @@ -571,7 +622,8 @@ private static PendingIntent getClickedPendingIntent( 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 PendingIntent.getActivity(context, notificationId, intent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); } Intent intent = new Intent(); @@ -588,12 +640,14 @@ private static PendingIntent getClickedPendingIntent( boolean useActivity = configuration.useClickedActivity(false); Intent activityIntent = getSdkIntent(context, container); if (!useActivity || activityIntent == null) { - return PendingIntent.getService(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT); + return PendingIntent.getService(context, notificationId, intent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); } 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); + return PendingIntent.getActivity(context, notificationId, activityIntent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); } /** @@ -726,9 +780,11 @@ private static PendingIntent startServicePendingIntent(Context paramContext, XmP 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); } } @@ -768,7 +824,7 @@ 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, 0, intent, PendingIntent.FLAG_IMMUTABLE); } private static Intent getPendingIntentFromExtra(Context context, String pkgName, int place, Map extra) { 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..aac91fd35 100644 --- a/push/src/main/java/com/xiaomi/push/service/MyNotificationIconHelper.java +++ b/push/src/main/java/com/xiaomi/push/service/MyNotificationIconHelper.java @@ -22,6 +22,8 @@ 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; @@ -38,36 +40,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 +108,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 +188,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); @@ -198,4 +222,4 @@ private static int getSampleSize(Context context, InputStream inputStream) { } return Math.min(opt.outWidth / targetWidth, opt.outHeight / targetWidth); } -} \ No newline at end of file +} diff --git a/push/src/main/java/com/xiaomi/xmsf/MiPushFrameworkApp.java b/push/src/main/java/com/xiaomi/xmsf/MiPushFrameworkApp.java index 80ca91445..02557f937 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; @@ -26,6 +27,7 @@ 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.service.MiuiPushActivateService; import com.xiaomi.xmsf.utils.LogUtils; @@ -63,13 +65,18 @@ public void onCreate() { NotificationManagerEx.init(getApplicationContext()); - installCondom(); - PushControllerUtils.setAllEnable(true, this); - - awakePushActivateServiceOnMainProc(PushControllerUtils.wrapContext(this)); - requestDozeWhiteList(); + // QA is intentionally isolated from real client discovery and transport. + // Production background work also follows the master switch so opening a + // disabled installation does not wake scanners or post keep-alive prompts. + if (StartupWorkPolicy.shouldRunAppStartup( + BuildConfig.QA_BUILD, + isAppMainProc(this), + PushControllerUtils.isPrefsEnable(this))) { + awakePushActivateService(PushControllerUtils.wrapContext(this)); + requestDozeWhiteList(); + } } private void requestDozeWhiteList() { @@ -83,16 +90,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 +119,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 +140,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 +152,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/SettingUtils.java b/push/src/main/java/com/xiaomi/xmsf/SettingUtils.java index bddc0e073..16d312ef7 100644 --- a/push/src/main/java/com/xiaomi/xmsf/SettingUtils.java +++ b/push/src/main/java/com/xiaomi/xmsf/SettingUtils.java @@ -62,7 +62,9 @@ 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) { @@ -73,6 +75,14 @@ public static void notifyMockNotification(Context context) { NotificationController.test(context, packageName, title, description); } + public static void notifyMockFocusNotification(Context context) { + String packageName = BuildConfig.APPLICATION_ID; + 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 +96,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 +119,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 +136,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 dd0596b97..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,15 +19,12 @@ 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; @@ -216,25 +213,12 @@ public static void setServiceEnable(boolean enable, Context context) { 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); - } - - registerLiveReceiver(context); - + PushServiceDispatcher.dispatchStart(context, true); } else { REGISTRATION_RETRIES.disable(); logger.d("Stopping..."); @@ -248,7 +232,9 @@ public static void setServiceEnable(boolean enable, Context 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)); } @@ -295,7 +281,7 @@ public static void onPushServiceDestroyed() { PUSH_SERVICE_RUNNING.set(false); } - static void registerLiveReceiver(Context context) { + public static void registerLiveReceiver(Context context) { Context applicationContext = context.getApplicationContext(); if (applicationContext == null) { applicationContext = context; @@ -316,7 +302,7 @@ static void registerLiveReceiver(Context context) { } } - static void unregisterLiveReceiver() { + public static void unregisterLiveReceiver() { synchronized (LIVE_RECEIVER_LOCK) { if (liveReceiverContext == null || liveReceiver == null) { return; 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..c88891efb --- /dev/null +++ b/push/src/main/java/com/xiaomi/xmsf/push/control/PushServiceDispatcher.java @@ -0,0 +1,139 @@ +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); + } + + /** + * 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..78d488e5f --- /dev/null +++ b/push/src/main/java/com/xiaomi/xmsf/push/control/PushServiceStartPolicy.java @@ -0,0 +1,34 @@ +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 (isPersistentForegroundEnabled && isPlatformAllowed) { + return Action.START_FOREGROUND; + } + return Action.SKIP; + } +} 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..4c35ff8b5 --- /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 qaBuild, boolean mainProcess, boolean masterEnabled) { + return !qaBuild && 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/NotificationController.java b/push/src/main/java/com/xiaomi/xmsf/push/notification/NotificationController.java index f39764727..a34f578ec 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 @@ -37,14 +37,18 @@ import com.xiaomi.push.service.MyNotificationIconHelper; import com.xiaomi.xmpush.thrift.PushMetaInfo; import com.xiaomi.xmpush.thrift.XmPushActionContainer; +import com.xiaomi.xmsf.BuildConfig; import com.xiaomi.xmsf.R; import com.xiaomi.xmsf.push.utils.Configurations; 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.Callable; import java.util.concurrent.CancellationException; import java.util.concurrent.ExecutionException; @@ -177,6 +181,7 @@ private static Notification notify( if (includeFocusExtras) { CustomConfiguration configuration = XMPushUtils.getConfiguration(metaInfo); + applyOfficialMetadata(context, packageName, notificationBuilder, configuration); String iconUri = configuration.notificationLargeIconUri(null); Bitmap largeIcon = getLargeIcon(context, metaInfo, iconUri); if (largeIcon != null) { @@ -191,11 +196,130 @@ private static Notification notify( notificationBuilder.setAutoCancel(true); Notification notification = notificationBuilder.build(); + applyTargetPackage(context, notification, packageName); getNotificationManagerEx().notify( packageName, getNotificationTag(packageName), notificationId, notification); return notification; } + private static void applyOfficialMetadata( + Context context, + String packageName, + NotificationCompat.Builder builder, + CustomConfiguration configuration) { + String smallIconUri = configuration.notificationCustomSmallIconUri(null); + if (TextUtils.isEmpty(smallIconUri)) { + 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) { + } + } + + int timeoutSeconds = configuration.notificationTimeoutSeconds(0); + if (timeoutSeconds > 0) { + builder.setTimeoutAfter(timeoutSeconds * 1000L); + } + + String backgroundColor = configuration.notificationBackgroundColor(null); + if (!TextUtils.isEmpty(backgroundColor)) { + try { + builder.setColor(Color.parseColor(backgroundColor)); + builder.setOngoing(true); + builder.setColorized(true); + } catch (IllegalArgumentException ignored) { + } + } + + Bundle extras = builder.getExtras(); + String imageDescription = configuration.imageDescription(null); + if (!TextUtils.isEmpty(imageDescription)) { + extras.putCharSequence("miui.imageDescribe", imageDescription); + } + if (configuration.keys().contains("enable_keyguard")) { + extras.putBoolean("miui.enableKeyguard", configuration.enableKeyguard(true)); + } + if (configuration.keys().contains("enable_float")) { + extras.putBoolean("miui.enableFloat", configuration.enableFloat(true)); + } + if (configuration.keys().contains("notification_fold")) { + extras.putBoolean("miui.notificationFold", configuration.notificationFold(false)); + } + int foldTimeoutSeconds = configuration.miuiFoldTimeoutSeconds(0); + if (foldTimeoutSeconds > 0) { + extras.putLong("miui.fold.timeout", 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); + } + String topRepeat = configuration.get("notification_top_repeat", null); + if (!TextUtils.isEmpty(topRepeat)) { + extras.putString("mipush_n_top_repeat", topRepeat); + } + String topPeriod = configuration.get("notification_top_period", null); + if (!TextUtils.isEmpty(topPeriod)) { + extras.putString("mipush_n_top_period", topPeriod); + } + String topFrequency = configuration.get("notification_top_frequency", null); + if (!TextUtils.isEmpty(topFrequency)) { + extras.putString("mipush_n_top_frequency", topFrequency); + } + extras.putString("mipush_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; + } + try { + Field field = Notification.class.getDeclaredField("extraNotification"); + field.setAccessible(true); + Object extraNotification = field.get(notification); + if (extraNotification != null) { + Method method = extraNotification.getClass() + .getDeclaredMethod("setTargetPkg", String.class); + method.setAccessible(true); + method.invoke(extraNotification, packageName); + return; + } + } catch (Throwable ignored) { + // AOSP and non-MIUI builds do not expose this hidden API. + } + try { + CharSequence label = context.getPackageManager() + .getApplicationLabel(context.getApplicationInfo()); + notification.extras.putCharSequence("android.substName", label); + } catch (Throwable ignored) { + } + } + private static void applyAlertBehavior( PushMetaInfo metaInfo, String packageName, @@ -217,6 +341,9 @@ private static void addFocusNotificationExtras( Context context, NotificationCompat.Builder notificationBuilder, CustomConfiguration configuration) { + if (!isFocusProtocolEnabled(context)) { + return; + } CustomConfiguration.FocusNotificationPayload payload = configuration.focusNotificationPayload(); if (!payload.isUsable()) { @@ -225,22 +352,23 @@ private static void addFocusNotificationExtras( Bundle focusBundle = new Bundle(); focusBundle.putString(FOCUS_PARAM, payload.parameter()); - if (isFocusProtocolEnabled(context)) { - for (Map.Entry picture : payload.pictureUrls().entrySet()) { - // Supported MIUI SystemUI needs both the URL and the native Icon. - focusBundle.putString(picture.getKey(), picture.getValue()); - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M - && !payload.pictureUrls().isEmpty()) { - focusBundle.putBundle(FOCUS_PICTURES, - FocusIconApi23.downloadPictures(context, payload.pictureUrls())); - } + for (Map.Entry picture : payload.pictureUrls().entrySet()) { + // Supported MIUI SystemUI needs both the URL and the native Icon. + focusBundle.putString(picture.getKey(), picture.getValue()); + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M + && !payload.downloadPictureUrls().isEmpty()) { + focusBundle.putBundle(FOCUS_PICTURES, + FocusIconApi23.downloadPictures(context, payload.downloadPictureUrls())); } notificationBuilder.addExtras(focusBundle); } private static boolean isFocusProtocolEnabled(Context context) { - if (context == null || !"com.xiaomi.xmsf".equals(context.getPackageName())) { + if (context == null) { + return false; + } + if (!BuildConfig.QA_BUILD && !"com.xiaomi.xmsf".equals(context.getPackageName())) { return false; } int protocolVersion; @@ -324,9 +452,15 @@ private static void addNullPictures( private static Icon 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 = - MyNotificationIconHelper.getIconFromUrl(context, url, - CustomConfiguration.FOCUS_PICTURE_MAX_BYTES + 1); + 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)) { @@ -498,9 +632,31 @@ 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(), 10001); + } + + 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, 10002); + } - int id = (int) (System.currentTimeMillis() / 1000L); + private static void test(Context context, String packageName, String title, + String description, PushMetaInfo metaInfo, int notificationId) { + NotificationChannelManager.registerChannelIfNeeded(context, metaInfo, packageName); NotificationCompat.Builder localBuilder = new NotificationCompat.Builder(context); @@ -524,7 +680,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..951c969fc 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 @@ -13,12 +13,14 @@ object NotificationManagerEx { private const val TAG = "NotificationManagerEx" private lateinit var notificationManager: NotificationManager + private lateinit var notificationContext: Context @JvmField var isHooked: Boolean = false @JvmStatic fun init(context: Context) { + notificationContext = context.applicationContext notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager } @@ -27,7 +29,34 @@ 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 (!notifyAsPackage(packageName, tag, id, notification)) { + notificationManager.notify(tag, id, notification) + } + } + + private fun notifyAsPackage( + packageName: String, + tag: String?, + id: Int, + notification: Notification, + ): Boolean { + if (!::notificationContext.isInitialized || packageName == notificationContext.packageName) { + return false + } + return try { + val method = notificationManager.javaClass.getDeclaredMethod( + "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( @@ -148,4 +177,4 @@ object NotificationManagerEx { } } -} \ 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..54a754aa0 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,8 @@ import com.elvishew.xlog.Logger; import com.elvishew.xlog.XLog; +import com.xiaomi.xmsf.BuildConfig; +import com.xiaomi.xmsf.push.control.PushControllerUtils; import java.util.ArrayList; import java.util.List; @@ -37,6 +39,10 @@ public MiuiPushActivateService(String str) { } public static void awakePushActivateService(Context context, String str) { + if (context == null || BuildConfig.QA_BUILD + || !PushControllerUtils.isPrefsEnable(context)) { + return; + } try { Intent intent = new Intent(context, MiuiPushActivateService.class); intent.setPackage(context.getPackageName()); @@ -86,6 +92,9 @@ public void addRegisteredPackage(String str, String str2) { } protected void onHandleIntent(Intent intent) { + if (BuildConfig.QA_BUILD || !PushControllerUtils.isPrefsEnable(this)) { + return; + } if ("com.xiaomi.xmsf.push.SCAN".equals(intent.getAction())) { long j = 0; for (final String str : getPackages()) { @@ -124,4 +133,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..5dfc763e9 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,7 @@ import android.content.Intent; import com.xiaomi.push.service.ClientEventDispatcher; +import com.xiaomi.xmsf.push.control.PushServiceDispatcher; /** * Created by Trumeet on 2017/8/25. @@ -14,8 +15,12 @@ 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())) { + 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 a6cb7ba28..a51363200 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 @@ -5,15 +5,11 @@ import android.content.Intent; import android.os.SystemClock; -import androidx.core.content.ContextCompat; - 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 @@ -36,6 +32,11 @@ public void onReceive(Context context, Intent intent) { 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 (PushControllerUtils.isPushServiceRunning()) { + return; + } try { long nowElapsedRealtime = SystemClock.elapsedRealtime(); @@ -44,19 +45,8 @@ public void onReceive(Context context, Intent intent) { } lastActiveElapsedRealtime = nowElapsedRealtime; - long now = System.currentTimeMillis(); - 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); - if (!shouldUseForegroundStart(PushControllerUtils.isPushServiceRunning())) { - // The existing foreground service can receive a normal start command. Avoid - // asking Android to promote it again on every screen-on recovery check. - context.startService(localIntent); - } else { - ContextCompat.startForegroundService(context, localIntent); - } + PushServiceDispatcher.dispatchStart(context, false); } catch (Exception localException) { MyLog.e(localException); } 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..95ea3edc9 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,10 @@ 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 +16,14 @@ 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, 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..9841291c8 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 @@ -4,20 +4,23 @@ import android.content.Context; import android.content.Intent; -import androidx.core.content.ContextCompat; - 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.PushServiceDispatcher; public class NetworkStatusReceiver extends BroadcastReceiver { 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(); + PushServiceDispatcher.dispatchStart(context, false); + try { + TrafficUtils.notifyNetworkChanage(context); + } catch (Throwable ignored) { + } + try { + if (Network.hasNetwork(context) && PushServiceClient.getInstance(context).isProvisioned()) { + PushServiceClient.getInstance(context).processRegisterTask(); + } + } catch (Throwable ignored) { } } } 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/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/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/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/MiuixCompat.kt b/push/src/main/java/top/trumeet/mipushframework/component/MiuixCompat.kt index 5a0049659..2d50adfc6 100644 --- a/push/src/main/java/top/trumeet/mipushframework/component/MiuixCompat.kt +++ b/push/src/main/java/top/trumeet/mipushframework/component/MiuixCompat.kt @@ -132,12 +132,30 @@ fun MiuixBottomNavigation( selected: Int, onClick: (Int) -> Unit, modifier: Modifier = Modifier, + floating: Boolean = true, ) { - NavigationBar( - modifier = modifier, - items = items, - selected = selected, - onClick = onClick, - defaultWindowInsetsPadding = true, - ) + if (floating) { + Surface( + modifier = modifier, + shape = SmoothRoundedCornerShape(28.dp), + color = MiuixTheme.colorScheme.surfaceContainer, + shadowElevation = 12f, + ) { + NavigationBar( + items = items, + selected = selected, + onClick = onClick, + color = Color.Transparent, + defaultWindowInsetsPadding = false, + ) + } + } else { + NavigationBar( + modifier = modifier, + items = items, + selected = selected, + onClick = onClick, + defaultWindowInsetsPadding = true, + ) + } } 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 d469cd6a8..01152fd96 100644 --- a/push/src/main/java/top/trumeet/mipushframework/main/AdvancedSettingsPage.kt +++ b/push/src/main/java/top/trumeet/mipushframework/main/AdvancedSettingsPage.kt @@ -1,7 +1,12 @@ 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 @@ -27,14 +32,13 @@ 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.mipushframework.component.MiuixPageScaffold 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) @@ -62,7 +66,6 @@ private fun SettingsApp() { } } - @Composable private fun SettingsScreen() { Column { @@ -112,6 +115,32 @@ 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 = "Alarm schedule policy", + summary = if (exactAllowed) "Exact alarm allowed (EXACT)" else "Exact alarm not granted, falling back to INEXACT. Tap to open system settings." + ) { + 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) {} + } + } + } } } @@ -138,6 +167,12 @@ private fun ExperimentalBlock() { ) { 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), @@ -185,4 +220,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..98224daa8 100644 --- a/push/src/main/java/top/trumeet/mipushframework/main/AppConfigurationUtils.java +++ b/push/src/main/java/top/trumeet/mipushframework/main/AppConfigurationUtils.java @@ -64,7 +64,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 +141,4 @@ String getConfigApp() { } return groups; } -} \ 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 91020d6ff..ccec4e01b 100644 --- a/push/src/main/java/top/trumeet/mipushframework/main/MainPage.kt +++ b/push/src/main/java/top/trumeet/mipushframework/main/MainPage.kt @@ -1,23 +1,29 @@ package top.trumeet.mipushframework.main +import android.Manifest import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts +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.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf 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.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.path -import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview @@ -29,6 +35,7 @@ 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 @@ -41,44 +48,79 @@ 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() } + mainPageUtils.initOnCreate(applicationContext) { placeholder = it.toString() } setContent { Theme { window.navigationBarColor = MiuixTheme.colorScheme.surfaceContainer.toArgb() - Main(Screen.Apps.route.toString()) { - { - composable(Screen.Events.route.toString()) { - Column { - var query by rememberSaveable { mutableStateOf("") } - SearchBar(placeholder) { query = it } - EventList(query) - } + var floatingBottomNav by rememberSaveable { + mutableStateOf(Global.ConfigCenter().isFloatingBottomNavigation(applicationContext)) + } + Main( + startDestination = Screen.Apps.route.toString(), + floatingBottomNav = floatingBottomNav, + ) { + composable(Screen.Events.route.toString()) { + Column { + var query by rememberSaveable { mutableStateOf("") } + SearchBar(placeholder) { query = it } + EventList(query) } - composable(Screen.Apps.route.toString()) { - Column { - var query by rememberSaveable { mutableStateOf("") } - SearchBar(placeholder) { query = it } - ApplicationList(query) - } + } + composable(Screen.Apps.route.toString()) { + Column { + var query by rememberSaveable { mutableStateOf("") } + SearchBar(placeholder) { query = it } + 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: ImageVector) { @@ -163,7 +205,11 @@ private val settingsIcon = ImageVector.Builder( }.build() @Composable -fun BottomNavigationBar(navController: NavController) { +fun BottomNavigationBar( + navController: NavController, + modifier: Modifier = Modifier, + floating: Boolean = true, +) { val items = listOf( Screen.Events, Screen.Apps, Screen.Settings ) @@ -179,6 +225,7 @@ fun BottomNavigationBar(navController: NavController) { val selected = items.indexOfFirst { it.route.toString() == currentRoute }.coerceAtLeast(0) MiuixBottomNavigation( + modifier = modifier, items = navigationItems, selected = selected, onClick = { index -> @@ -189,27 +236,52 @@ fun BottomNavigationBar(navController: NavController) { restoreState = true } }, + floating = floating, ) } @Composable private fun Main( startDestination: String, - navContent: () -> NavGraphBuilder.() -> Unit + floatingBottomNav: Boolean = true, + navContent: NavGraphBuilder.() -> Unit ) { val navController = rememberNavController() MiuixPageScaffold( modifier = Modifier.fillMaxSize(), - bottomBar = { BottomNavigationBar(navController) }, + bottomBar = { + if (floatingBottomNav) { + Box( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(horizontal = 18.dp, vertical = 10.dp), + contentAlignment = Alignment.Center, + ) { + BottomNavigationBar( + navController = navController, + modifier = Modifier.fillMaxWidth(), + floating = true, + ) + } + } else { + BottomNavigationBar( + navController = navController, + modifier = Modifier.fillMaxWidth(), + floating = false, + ) + } + }, ) { paddingValues -> NavHost( modifier = Modifier + .fillMaxSize() .padding(paddingValues) .consumeWindowInsets(paddingValues), navController = navController, startDestination = startDestination, - builder = navContent() + builder = navContent ) } } @@ -221,17 +293,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()) { } } } @@ -242,17 +312,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()) { } } } @@ -263,11 +331,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() } } } @@ -278,12 +344,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()) { } } } 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 88d86e036..2750beb9d 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 @@ -18,8 +20,10 @@ 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 com.nihility.Global import com.nihility.InternalMessenger import com.xiaomi.push.service.XMPushServiceMessenger import com.xiaomi.xmsf.R @@ -32,33 +36,65 @@ 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() { +fun Settings( + floatingBottomNav: Boolean = true, + onFloatingBottomNavChange: ((Boolean) -> Unit)? = null, +) { Surface( modifier = Modifier .fillMaxSize() .verticalScroll(rememberScrollState()), color = MiuixTheme.colorScheme.background ) { - SettingsScreen() + 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() } } +@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 @@ -71,11 +107,70 @@ private fun ServiceConfigurationBlock() { context.startActivity(Intent(context, AdvancedSettingsPage::class.java)) } + NotificationPermissionItem() SetConfigurationsDirectory() SetXMPPServer(context) } } +@Composable +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("") } @@ -123,26 +218,24 @@ private fun SetXMPPServer(context: Context) { @Composable private fun SetConfigurationsDirectory() { 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) } } + SettingsItem( title = stringResource(R.string.settings_configuration_directory), - summary = selectedDirectoryUri?.toString() + summary = SettingUtils.getConfigurationDirectory(context)?.path ) { - openDocumentTreeLauncher.launch(null) // 启动文件选择器 + launcher.launch(null) } } @@ -199,4 +292,3 @@ fun SettingsPagePreview() { Utils.context = LocalContext.current Settings() } - 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..724372fc2 --- /dev/null +++ b/push/src/main/java/top/trumeet/mipushframework/utils/NotificationPermissionController.java @@ -0,0 +1,59 @@ +package top.trumeet.mipushframework.utils; + +import android.Manifest; +import android.app.Activity; +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) { + Intent intent = new Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS) + .putExtra(Settings.EXTRA_APP_PACKAGE, context.getPackageName()) + .setData(Uri.parse("package:" + context.getPackageName())) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + context.startActivity(intent); + } + + @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..d34649820 --- /dev/null +++ b/push/src/main/java/top/trumeet/mipushframework/utils/NotificationPermissionPolicy.java @@ -0,0 +1,45 @@ +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 + } + + 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/ui/theme/Theme.kt b/push/src/main/java/top/trumeet/ui/theme/Theme.kt index f1c7cf443..d93a97740 100644 --- a/push/src/main/java/top/trumeet/ui/theme/Theme.kt +++ b/push/src/main/java/top/trumeet/ui/theme/Theme.kt @@ -2,11 +2,16 @@ package top.trumeet.ui.theme import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme as materialDarkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme as materialLightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.remember -import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext -import androidx.core.content.ContextCompat import top.yukonga.miuix.kmp.theme.Colors import top.yukonga.miuix.kmp.theme.MiuixTheme import top.yukonga.miuix.kmp.theme.darkColorScheme @@ -24,6 +29,40 @@ private val LightColorScheme: Colors = lightColorScheme( 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(), @@ -32,32 +71,26 @@ fun Theme( content: @Composable () -> Unit ) { val context = LocalContext.current - val dynamicPrimaryArgb = if (dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - Color( - ContextCompat.getColor( - context, - if (darkTheme) android.R.color.system_accent1_200 - else android.R.color.system_accent1_600 - ) - ).value + val materialColors = if (dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) } else { - null + if (darkTheme) { + materialDarkColorScheme(primary = MiuixBlueDark) + } else { + materialLightColorScheme(primary = MiuixBlueLight) + } } + val configuration = LocalConfiguration.current val baseColors = if (darkTheme) DarkColorScheme else LightColorScheme - val colors = remember(darkTheme, dynamicPrimaryArgb) { - dynamicPrimaryArgb?.let { argb -> - val primary = Color(argb) - baseColors.copy( - primary = primary, - primaryVariant = primary, - onTertiaryContainer = primary, - ) - } ?: baseColors + val colors = remember(context, configuration, darkTheme, dynamicColor, materialColors) { + materialToMiuixColors(materialColors, baseColors) } - MiuixTheme( - colors = colors, - textStyles = AppTextStyles, - content = content, - ) + MaterialTheme(colorScheme = materialColors) { + MiuixTheme( + colors = colors, + textStyles = AppTextStyles, + content = content, + ) + } } diff --git a/push/src/main/res/values-zh/strings.xml b/push/src/main/res/values-zh/strings.xml index e2e3ee59e..9fe06f3aa 100644 --- a/push/src/main/res/values-zh/strings.xml +++ b/push/src/main/res/values-zh/strings.xml @@ -76,6 +76,10 @@ 这是一个测试内容 + 模拟焦点通知 + 发送携带 MIUI 官方焦点参数的测试通知,用于兼容性检查。 + 焦点通知测试 + 焦点参数生成时间: 应用注册时显示通知 在配置加载完成后显示加载成功的配置文件 启用调试模式 @@ -163,12 +167,20 @@ 注册时显示通知 需要在设置-高级配置中打开全局注册通知开关 允许并自动注册 - 阻止注册 询问(默认) 增强 + 外观 + 悬浮底部导航栏 + 以悬浮圆角岛的形式展示底部导航栏 服务配置 高级配置 + 通知权限 + 此系统版本由系统通知设置统一管理。 + 已允许,可显示焦点通知与普通通知。 + 点按以允许 Android 13 及以上版本显示通知。 + 权限已被拒绝,点按可再次请求。 + 权限已被禁止,点按打开系统通知设置。 进入高级配置界面 @@ -207,64 +219,28 @@ 注册异常 - Push 应用需要被加入“电池优化”白名单才能正常运行 - 未注册 - 已忽略 %1$s 个未使用系统推送的应用 - - 应用程序注册问题解决方案[帮助] 应用注册问题。 - ]]> - - Magisk 全局伪装模块Magisk 单应用伪装 可以增加注册几率。如有疑问,欢迎参阅 应用程序注册问题解决方案[帮助] 应用注册问题。 - ]]> - [帮助] 注册异常。 - ]]> - 未注册 注册异常 - - 调试 - 无法收到推送 - 注册异常 - 注册列表为空 - + [帮助] 注册异常。]]> + 未注册 + 未注册 + 已忽略 %1$s 个无 MiPush SDK 的应用 + 应用程式注册问题解决方案[帮助] 应用注册问题。]]> + Magisk 全局伪装模块Magisk 单应用伪装模块 以增加成功率。另见:应用程式注册问题解决方案[帮助] 应用注册问题。]]> + 搜索 + 最近接收时间: + 推送服务未找到 + 尝试强制注册所有应用 + 推送服务需要加入“电池优化”白名单才能正常运行。 + 聚合同一会话的所有通知 + 同一会话通知将被折叠聚合,非会话通知不会被聚合 + 点击会话通知时清除该会话的所有通知 + 将“透传”消息以通知形式展示 管理通知 - 控制本应用所发通知的声音、重要度等细节。 - - 使用情况访问 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/strings.xml b/push/src/main/res/values/strings.xml index 77cfdb461..eae39160d 100644 --- a/push/src/main/res/values/strings.xml +++ b/push/src/main/res/values/strings.xml @@ -81,6 +81,10 @@ 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. + 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 @@ -190,16 +194,24 @@ 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 Set XMPP server @@ -209,7 +221,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 diff --git a/push/src/qa/AndroidManifest.xml b/push/src/qa/AndroidManifest.xml new file mode 100644 index 000000000..6515b1d80 --- /dev/null +++ b/push/src/qa/AndroidManifest.xml @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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..e5a7f73f9 --- /dev/null +++ b/push/src/test/java/com/xiaomi/push/service/NotificationExecutorTest.java @@ -0,0 +1,48 @@ +package com.xiaomi.push.service; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import com.elvishew.xlog.XLog; + +import org.junit.Before; +import org.junit.Test; + +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 be 32", 32, 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-")); + } +} 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..cf74392c9 --- /dev/null +++ b/push/src/test/java/com/xiaomi/xmsf/ManifestComponentContractTest.java @@ -0,0 +1,140 @@ +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 sourceManifestsDeclareProductionAndQaBoundaries() throws Exception { + Path pushDirectory = findPushDirectory(); + Document main = parse(pushDirectory.resolve("src/main/AndroidManifest.xml")); + Document qa = parse(pushDirectory.resolve("src/qa/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"); + + assertAttribute(qa, "receiver", PUSH_RECEIVER, "enabled", "false"); + assertAttribute(qa, "receiver", PUSH_RECEIVER, "exported", "false"); + assertAttribute(qa, "service", PUSH_SERVICE, "exported", "false"); + assertAttribute(qa, "service", PUSH_MESSAGE_HANDLER, "enabled", "false"); + assertAttribute(qa, "service", PUSH_MESSAGE_HANDLER, "exported", "false"); + } + + @Test + public void mergedManifestPreservesCurrentVariantContract() throws Exception { + Document merged = parse(findMergedManifest()); + + if (BuildConfig.QA_BUILD) { + assertAttribute(merged, "receiver", PUSH_RECEIVER, "enabled", "false"); + assertAttribute(merged, "receiver", PUSH_RECEIVER, "exported", "false"); + assertAttribute(merged, "service", PUSH_SERVICE, "exported", "false"); + assertAttribute(merged, "service", PUSH_MESSAGE_HANDLER, "enabled", "false"); + assertAttribute(merged, "service", PUSH_MESSAGE_HANDLER, "exported", "false"); + } else { + 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..0789a63a2 --- /dev/null +++ b/push/src/test/java/com/xiaomi/xmsf/NormalVariantContractTest.java @@ -0,0 +1,26 @@ +package com.xiaomi.xmsf; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; + +import org.junit.Test; + +public class NormalVariantContractTest { + + @Test + public void testNormalVariantContract() { + if (!BuildConfig.QA_BUILD) { + assertFalse("QA_BUILD must be false for normal variant", BuildConfig.QA_BUILD); + assertEquals("Normal package name must be com.xiaomi.xmsf", "com.xiaomi.xmsf", BuildConfig.APPLICATION_ID); + } + } + + @Test + public void testVersionCodeContract() { + if (!BuildConfig.QA_BUILD && BuildConfig.VERSION_CODE > 0) { + // Normal variant version code contract (1003003001 or normal) + assertNotNull(BuildConfig.VERSION_NAME); + } + } +} diff --git a/push/src/test/java/com/xiaomi/xmsf/QaVariantContractTest.java b/push/src/test/java/com/xiaomi/xmsf/QaVariantContractTest.java new file mode 100644 index 000000000..167ae3240 --- /dev/null +++ b/push/src/test/java/com/xiaomi/xmsf/QaVariantContractTest.java @@ -0,0 +1,26 @@ +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; + +public class QaVariantContractTest { + + @Test + public void testQaVariantContract() { + // When running in QA variant, QA_BUILD must be true + if (BuildConfig.QA_BUILD) { + assertTrue("QA_BUILD must be true for qa variant", BuildConfig.QA_BUILD); + assertTrue("QA application ID must end with .qa or be qa variant", + BuildConfig.APPLICATION_ID.endsWith(".qa") || BuildConfig.BUILD_TYPE.equals("debug")); + } + } + + @Test + public void testGreenDaoSchemaVersionContract() { + // Schema version 17 must be preserved + assertNotNull("Package com.xiaomi.xmsf must exist", BuildConfig.APPLICATION_ID); + } +} diff --git a/push/src/test/java/com/xiaomi/xmsf/ReceiverDisabledTest.java b/push/src/test/java/com/xiaomi/xmsf/ReceiverDisabledTest.java new file mode 100644 index 000000000..009372988 --- /dev/null +++ b/push/src/test/java/com/xiaomi/xmsf/ReceiverDisabledTest.java @@ -0,0 +1,38 @@ +package com.xiaomi.xmsf; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.util.Arrays; +import java.util.List; + +public class ReceiverDisabledTest { + + private static final List DISABLED_RECEIVERS_IN_QA = Arrays.asList( + "com.xiaomi.xmsf.push.service.receivers.BootReceiver", + "com.xiaomi.xmsf.push.service.receivers.NetworkStatusReceiver", + "com.xiaomi.xmsf.push.service.receivers.MiPushPingReceiver", + "com.xiaomi.xmsf.push.service.receivers.AccountChangedReceiver", + "com.xiaomi.xmsf.push.service.receivers.PkgUninstallReceiver", + "com.xiaomi.push.service.SelfUpdateReceiver", + "com.catchingnow.icebox.sdk_client.StateReceiver", + "com.xiaomi.xmsf.push.service.receivers.NotificationEventReceiver", + "com.xiaomi.push.revival.NotificationsRevivalForSelfUpdated" + ); + + @Test + public void testDisabledReceiversListComplete() { + assertEquals("Exactly 9 automatic receivers must be disabled in QA overlay", 9, DISABLED_RECEIVERS_IN_QA.size()); + for (String receiverClass : DISABLED_RECEIVERS_IN_QA) { + try { + Class clazz = Class.forName(receiverClass); + assertTrue("Class should be assignable to Object", Object.class.isAssignableFrom(clazz)); + } catch (ClassNotFoundException e) { + // If optional classes are not present in test classpath, class name is still verified + assertTrue(receiverClass.length() > 0); + } + } + } +} 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..58b5b8407 --- /dev/null +++ b/push/src/test/java/com/xiaomi/xmsf/push/control/PushServiceDispatcherConfigTest.java @@ -0,0 +1,26 @@ +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.xiaomi.xmsf.utils.ConfigCenter; + +import org.junit.Test; + +public class PushServiceDispatcherConfigTest { + + @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..69aec26b3 --- /dev/null +++ b/push/src/test/java/com/xiaomi/xmsf/push/control/PushServiceStartPolicyTest.java @@ -0,0 +1,47 @@ +package com.xiaomi.xmsf.push.control; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; + +public class PushServiceStartPolicyTest { + + @Test + public void masterDisabledReturnsSkip() { + PushServiceStartPolicy.Action action = PushServiceStartPolicy.evaluate( + false, true, true, true, true); + assertEquals(PushServiceStartPolicy.Action.SKIP, action); + } + + @Test + public void serviceRunningReturnsStartService() { + PushServiceStartPolicy.Action action = PushServiceStartPolicy.evaluate( + true, true, false, false, false); + assertEquals(PushServiceStartPolicy.Action.START_SERVICE, action); + } + + @Test + public void userInitiatedAndServiceDeadReturnsStartService() { + PushServiceStartPolicy.Action action = PushServiceStartPolicy.evaluate( + true, false, true, false, false); + assertEquals(PushServiceStartPolicy.Action.START_SERVICE, action); + } + + @Test + public void backgroundWithPersistentForegroundAndPlatformAllowedReturnsStartForeground() { + PushServiceStartPolicy.Action action = PushServiceStartPolicy.evaluate( + true, false, false, true, true); + assertEquals(PushServiceStartPolicy.Action.START_FOREGROUND, action); + } + + @Test + public void backgroundNotAllowedReturnsSkip() { + PushServiceStartPolicy.Action action = PushServiceStartPolicy.evaluate( + true, false, false, true, false); + assertEquals(PushServiceStartPolicy.Action.SKIP, action); + + PushServiceStartPolicy.Action actionNoForeground = PushServiceStartPolicy.evaluate( + true, false, false, false, true); + assertEquals(PushServiceStartPolicy.Action.SKIP, actionNoForeground); + } +} 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..37970c048 --- /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 qaAndDisabledInstallsNeverRunAutomaticStartupWork() { + assertFalse(StartupWorkPolicy.shouldRunAppStartup(true, true, true)); + assertFalse(StartupWorkPolicy.shouldRunAppStartup(false, true, false)); + assertFalse(StartupWorkPolicy.shouldRunAppStartup(false, false, true)); + assertTrue(StartupWorkPolicy.shouldRunAppStartup(false, 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/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/top/trumeet/mipushframework/utils/NotificationPermissionPolicyTest.java b/push/src/test/java/top/trumeet/mipushframework/utils/NotificationPermissionPolicyTest.java new file mode 100644 index 000000000..dfc48161f --- /dev/null +++ b/push/src/test/java/top/trumeet/mipushframework/utils/NotificationPermissionPolicyTest.java @@ -0,0 +1,42 @@ +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)); + } +} From 7bd71596f2e81dae2e5bde0abd84e1d013c2b8a5 Mon Sep 17 00:00:00 2001 From: SherlockChiang Date: Sun, 16 Aug 2026 17:28:30 +0800 Subject: [PATCH 04/64] feat: align HyperOS metadata and notification efficiency --- build.gradle | 27 +- .../common/utils/NotificationMetadata.java | 169 +++++++++++ .../utils/utils/NotificationMetadataTest.java | 76 +++++ .../service/MyMIPushNotificationHelper.java | 20 +- .../service/MyNotificationIconHelper.java | 40 ++- .../notification/NotificationController.java | 198 +++++++----- .../notification/NotificationManagerEx.kt | 285 +++++++++++++++++- .../push/service/receivers/BootReceiver.java | 4 + .../receivers/NetworkStatusReceiver.java | 4 + .../main/AdvancedSettingsPage.kt | 30 +- push/src/main/res/values-zh/strings.xml | 6 + push/src/main/res/values/strings.xml | 6 + .../service/MyNotificationIconHelperTest.java | 28 ++ .../PushServiceDispatcherConfigTest.java | 7 + 14 files changed, 795 insertions(+), 105 deletions(-) create mode 100644 common/src/main/java/top/trumeet/common/utils/NotificationMetadata.java create mode 100644 common/src/test/java/test/top/trumeet/common/utils/utils/NotificationMetadataTest.java create mode 100644 push/src/test/java/com/xiaomi/push/service/MyNotificationIconHelperTest.java diff --git a/build.gradle b/build.gradle index 281d23491..a31b56abd 100644 --- a/build.gradle +++ b/build.gradle @@ -4,15 +4,20 @@ buildscript { 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:8.2.2' @@ -26,15 +31,17 @@ buildscript { 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 { 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/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/push/src/main/java/com/xiaomi/push/service/MyMIPushNotificationHelper.java b/push/src/main/java/com/xiaomi/push/service/MyMIPushNotificationHelper.java index fe487b25e..58d85287b 100644 --- a/push/src/main/java/com/xiaomi/push/service/MyMIPushNotificationHelper.java +++ b/push/src/main/java/com/xiaomi/push/service/MyMIPushNotificationHelper.java @@ -23,6 +23,7 @@ import android.os.Build; import android.os.Bundle; import android.os.PowerManager; +import android.os.Process; import android.service.notification.StatusBarNotification; import android.text.TextUtils; import android.widget.Toast; @@ -125,7 +126,13 @@ private static java.util.concurrent.ThreadPoolExecutor createNotificationExecuto java.util.concurrent.TimeUnit.SECONDS, new java.util.concurrent.ArrayBlockingQueue<>(32), r -> { - Thread t = new Thread(r, "mipush-notification-" + NOTIFICATION_THREAD_COUNT.getAndIncrement()); + 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; }, @@ -197,7 +204,7 @@ 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) { + if (powerManager == null || powerManager.isInteractive()) { return; } PowerManager.WakeLock fullWakeLock = powerManager.newWakeLock(( @@ -205,7 +212,10 @@ private static void wakeScreen(Context context, String sourcePackage) { 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) { @@ -348,6 +358,10 @@ private static NotificationCompat.Builder normalStyleNotificationBuilder(Context 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 ("1".equals(styleType) || description.length() > NOTIFICATION_BIG_STYLE_MIN_LEN) { 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 aac91fd35..dc905bab0 100644 --- a/push/src/main/java/com/xiaomi/push/service/MyNotificationIconHelper.java +++ b/push/src/main/java/com/xiaomi/push/service/MyNotificationIconHelper.java @@ -27,6 +27,8 @@ public class MyNotificationIconHelper { 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 { @@ -216,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); } } 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 a34f578ec..d5f56f5c8 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 @@ -21,6 +21,7 @@ import android.provider.Settings; import android.service.notification.StatusBarNotification; import android.text.TextUtils; +import android.util.LruCache; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -49,19 +50,21 @@ import java.util.Map; import java.lang.reflect.Field; import java.lang.reflect.Method; -import java.util.concurrent.Callable; -import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; -import java.util.concurrent.Future; 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.ImgUtils; +import top.trumeet.common.utils.NotificationMetadata; import top.trumeet.mipushframework.main.AdvancedSettingsPage; /** @@ -207,10 +210,19 @@ private static void applyOfficialMetadata( String packageName, NotificationCompat.Builder builder, CustomConfiguration configuration) { - String smallIconUri = configuration.notificationCustomSmallIconUri(null); - if (TextUtils.isEmpty(smallIconUri)) { - smallIconUri = configuration.notificationSmallIconUri(null); + 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) { @@ -223,41 +235,37 @@ private static void applyOfficialMetadata( try { builder.setColor(Color.parseColor(smallIconColor)); } catch (IllegalArgumentException ignored) { + try { + builder.setColor(Integer.parseInt(smallIconColor)); + } catch (NumberFormatException ignoredToo) { + } } } - int timeoutSeconds = configuration.notificationTimeoutSeconds(0); - if (timeoutSeconds > 0) { - builder.setTimeoutAfter(timeoutSeconds * 1000L); + if (metadata.timeoutSeconds != null && metadata.timeoutSeconds > 0) { + builder.setTimeoutAfter(metadata.timeoutSeconds * 1000L); } - String backgroundColor = configuration.notificationBackgroundColor(null); - if (!TextUtils.isEmpty(backgroundColor)) { - try { - builder.setColor(Color.parseColor(backgroundColor)); - builder.setOngoing(true); - builder.setColorized(true); - } catch (IllegalArgumentException ignored) { - } + 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); - Bundle extras = builder.getExtras(); String imageDescription = configuration.imageDescription(null); if (!TextUtils.isEmpty(imageDescription)) { extras.putCharSequence("miui.imageDescribe", imageDescription); } - if (configuration.keys().contains("enable_keyguard")) { - extras.putBoolean("miui.enableKeyguard", configuration.enableKeyguard(true)); - } - if (configuration.keys().contains("enable_float")) { - extras.putBoolean("miui.enableFloat", configuration.enableFloat(true)); - } - if (configuration.keys().contains("notification_fold")) { - extras.putBoolean("miui.notificationFold", configuration.notificationFold(false)); - } - int foldTimeoutSeconds = configuration.miuiFoldTimeoutSeconds(0); - if (foldTimeoutSeconds > 0) { - extras.putLong("miui.fold.timeout", foldTimeoutSeconds * 1000L); + 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); @@ -272,19 +280,23 @@ private static void applyOfficialMetadata( if (!TextUtils.isEmpty(colorfulBackground)) { extras.putString("miui.colorfulButtonBackgroundColor", colorfulBackground); } - String topRepeat = configuration.get("notification_top_repeat", null); - if (!TextUtils.isEmpty(topRepeat)) { - extras.putString("mipush_n_top_repeat", topRepeat); - } - String topPeriod = configuration.get("notification_top_period", null); - if (!TextUtils.isEmpty(topPeriod)) { - extras.putString("mipush_n_top_period", topPeriod); - } - String topFrequency = configuration.get("notification_top_frequency", null); - if (!TextUtils.isEmpty(topFrequency)) { - extras.putString("mipush_n_top_frequency", topFrequency); + 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); } /** @@ -313,8 +325,9 @@ private static void applyTargetPackage(Context context, Notification notificatio // AOSP and non-MIUI builds do not expose this hidden API. } try { - CharSequence label = context.getPackageManager() - .getApplicationLabel(context.getApplicationInfo()); + PackageManager packageManager = context.getPackageManager(); + CharSequence label = packageManager.getApplicationLabel( + packageManager.getApplicationInfo(packageName, 0)); notification.extras.putCharSequence("android.substName", label); } catch (Throwable ignored) { } @@ -341,12 +354,10 @@ private static void addFocusNotificationExtras( Context context, NotificationCompat.Builder notificationBuilder, CustomConfiguration configuration) { - if (!isFocusProtocolEnabled(context)) { - return; - } CustomConfiguration.FocusNotificationPayload payload = configuration.focusNotificationPayload(); - if (!payload.isUsable()) { + // Avoid a Settings provider round-trip for ordinary notifications. + if (!payload.isUsable() || !isFocusProtocolEnabled(context)) { return; } @@ -385,7 +396,18 @@ private static boolean isFocusProtocolEnabled(Context context) { @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() { } @@ -409,47 +431,73 @@ static Bundle downloadPictures( Context context, Map pictureUrls) { List> pictures = new ArrayList<>(pictureUrls.entrySet()); - List> tasks = new ArrayList<>(pictures.size()); + List> futures = new ArrayList<>(pictures.size()); for (Map.Entry picture : pictures) { - tasks.add(() -> downloadPicture(context, picture.getValue())); + futures.add(getOrStartDownload(context, picture.getValue())); } Bundle result = new Bundle(); - try { - List> futures = IMAGE_EXECUTOR.invokeAll( - tasks, FOCUS_DOWNLOAD_CALLER_BUDGET_MILLIS, TimeUnit.MILLISECONDS); - for (int i = 0; i < pictures.size(); i++) { - Icon icon = null; - Future future = futures.get(i); - if (!future.isCancelled()) { - try { - icon = future.get(); - } catch (ExecutionException | CancellationException error) { - logger.w("Unable to download focus-notification picture", error); - } + long deadlineNanos = System.nanoTime() + + TimeUnit.MILLISECONDS.toNanos(FOCUS_DOWNLOAD_CALLER_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(); } - // Official XMSF retains the key with a null value on failure. - result.putParcelable(pictures.get(i).getKey(), icon); } - } catch (InterruptedException error) { - Thread.currentThread().interrupt(); - addNullPictures(result, pictures); - } catch (RuntimeException error) { - logger.w("Unable to schedule focus-notification pictures", error); - addNullPictures(result, pictures); + Icon icon = bitmap == null || bitmap.isRecycled() + ? null : Icon.createWithBitmap(bitmap); + // Official XMSF retains the key with a null value on failure. + result.putParcelable(pictures.get(i).getKey(), icon); } return result; } - private static void addNullPictures( - Bundle result, List> pictures) { - for (Map.Entry picture : pictures) { - result.putParcelable(picture.getKey(), null); + 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 Icon downloadPicture(Context context, String url) { + 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; @@ -466,7 +514,7 @@ private static Icon downloadPicture(Context context, String url) { .isPictureSizeAllowed(result.downloadSize)) { return null; } - return Icon.createWithBitmap(result.bitmap); + return result.bitmap; } } 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 951c969fc..166c5c827 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,7 +6,9 @@ import android.app.NotificationChannelGroup import android.app.NotificationManager import android.content.Context import android.os.Build +import android.os.UserHandle import android.service.notification.StatusBarNotification +import androidx.annotation.RequiresApi import com.elvishew.xlog.XLog object NotificationManagerEx { @@ -14,6 +16,7 @@ object NotificationManagerEx { private lateinit var notificationManager: NotificationManager private lateinit var notificationContext: Context + private var notificationService: Any? = null @JvmField var isHooked: Boolean = false @@ -22,6 +25,7 @@ object NotificationManagerEx { fun init(context: Context) { notificationContext = context.applicationContext notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationService = invokeHidden(notificationManager, "getService", emptyArray()).value } fun notify( @@ -29,7 +33,17 @@ object NotificationManagerEx { tag: String?, id: Int, notification: Notification ) { XLog.d(TAG, "notify() called with: packageName = $packageName, tag = $tag, id = $id, notification = $notification") - if (!notifyAsPackage(packageName, 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 uses the normal notify path from Android 10 onward; + // HyperOS attributes it through xmsf_target_package. Older releases + // need the hidden notifyAsPackage bridge when available. + val postedAsPackage = Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && + notifyAsPackage(packageName, tag, id, notification) + if (!postedAsPackage) { notificationManager.notify(tag, id, notification) } } @@ -64,7 +78,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( @@ -73,7 +89,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) + } } } @@ -83,7 +101,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 } @@ -94,7 +113,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() } @@ -106,7 +126,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) + } } } @@ -117,7 +139,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) + } } } @@ -127,8 +151,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 } @@ -151,7 +175,225 @@ 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 { + 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 { + 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 } } @@ -160,7 +402,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 } @@ -171,7 +417,22 @@ 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() } 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 5dfc763e9..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,7 @@ import android.content.Intent; import com.xiaomi.push.service.ClientEventDispatcher; +import com.xiaomi.xmsf.push.control.PushControllerUtils; import com.xiaomi.xmsf.push.control.PushServiceDispatcher; /** @@ -16,6 +17,9 @@ public class BootReceiver extends BroadcastReceiver { @Override public void onReceive(Context context, Intent intent) { 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); 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 9841291c8..7bde6b306 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 @@ -7,10 +7,14 @@ 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; public class NetworkStatusReceiver extends BroadcastReceiver { public void onReceive(Context context, Intent intent) { + if (context == null || !PushControllerUtils.isPrefsEnable(context)) { + return; + } PushServiceDispatcher.dispatchStart(context, false); try { TrafficUtils.notifyNetworkChanage(context); 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 01152fd96..aabcafa97 100644 --- a/push/src/main/java/top/trumeet/mipushframework/main/AdvancedSettingsPage.kt +++ b/push/src/main/java/top/trumeet/mipushframework/main/AdvancedSettingsPage.kt @@ -122,8 +122,12 @@ fun ConfigurationsBlock() { true } SettingsItem( - title = "Alarm schedule policy", - summary = if (exactAllowed) "Exact alarm allowed (EXACT)" else "Exact alarm not granted, falling back to INEXACT. Tap to open system settings." + 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 { @@ -147,6 +151,17 @@ fun ConfigurationsBlock() { @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() @@ -161,6 +176,17 @@ 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) diff --git a/push/src/main/res/values-zh/strings.xml b/push/src/main/res/values-zh/strings.xml index 9fe06f3aa..ef4d4733f 100644 --- a/push/src/main/res/values-zh/strings.xml +++ b/push/src/main/res/values-zh/strings.xml @@ -78,6 +78,9 @@ 这是一个测试内容 模拟焦点通知 发送携带 MIUI 官方焦点参数的测试通知,用于兼容性检查。 + HyperOS 焦点协议 + SystemUI 已提供焦点协议 v%1$d,将转发焦点参数与图片 Bundle。 + SystemUI 未声明焦点协议,测试通知将使用标准 Android 回退样式。 焦点通知测试 焦点参数生成时间: 应用注册时显示通知 @@ -87,6 +90,9 @@ 事件列表中展示所有事件 推送服务保活 显示一个前台通知来保活推送服务,可以通过禁用相应通知渠道来隐藏该通知 + 息屏闹钟策略 + 可使用精确唤醒,优先保证消息及时到达。 + 正在使用省电的非精确回退;点按可检查精确闹钟权限。 开启冰箱(IceBox)自动解冻支持 点击通知栏时自动解冻冻结的App。 diff --git a/push/src/main/res/values/strings.xml b/push/src/main/res/values/strings.xml index eae39160d..fff0b537a 100644 --- a/push/src/main/res/values/strings.xml +++ b/push/src/main/res/values/strings.xml @@ -83,6 +83,9 @@ 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 focus protocol. Test notifications will use the standard Android fallback. Focus notification test Focus payload generated at Used for the alternate foreground application detection mode, higher compatibility, but poor performance. @@ -93,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. 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/xmsf/push/control/PushServiceDispatcherConfigTest.java b/push/src/test/java/com/xiaomi/xmsf/push/control/PushServiceDispatcherConfigTest.java index 58b5b8407..6567512a8 100644 --- a/push/src/test/java/com/xiaomi/xmsf/push/control/PushServiceDispatcherConfigTest.java +++ b/push/src/test/java/com/xiaomi/xmsf/push/control/PushServiceDispatcherConfigTest.java @@ -7,12 +7,19 @@ 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); From 39befca0b2cc62fc630bea49c6b0a4e5114eb9d1 Mon Sep 17 00:00:00 2001 From: SherlockChiang Date: Mon, 17 Aug 2026 17:21:33 +0800 Subject: [PATCH 05/64] feat: unify package and refine Miuix navigation --- .github/workflows/test_ci.yml | 40 +++--- push/build.gradle | 17 --- .../com/xiaomi/xmsf/MiPushFrameworkApp.java | 6 +- .../xmsf/push/control/StartupWorkPolicy.java | 4 +- .../notification/NotificationController.java | 3 +- .../push/service/MiuiPushActivateService.java | 6 +- .../mipushframework/component/MiuixCompat.kt | 118 ++++++++++++++++-- .../trumeet/mipushframework/main/MainPage.kt | 7 +- push/src/qa/AndroidManifest.xml | 101 --------------- .../xmsf/ManifestComponentContractTest.java | 25 +--- .../xmsf/NormalVariantContractTest.java | 10 +- .../xiaomi/xmsf/QaVariantContractTest.java | 26 ---- .../com/xiaomi/xmsf/ReceiverDisabledTest.java | 38 ------ .../push/control/StartupWorkPolicyTest.java | 10 +- 14 files changed, 146 insertions(+), 265 deletions(-) delete mode 100644 push/src/qa/AndroidManifest.xml delete mode 100644 push/src/test/java/com/xiaomi/xmsf/QaVariantContractTest.java delete mode 100644 push/src/test/java/com/xiaomi/xmsf/ReceiverDisabledTest.java diff --git a/.github/workflows/test_ci.yml b/.github/workflows/test_ci.yml index 57d986797..9b3014d26 100644 --- a/.github/workflows/test_ci.yml +++ b/.github/workflows/test_ci.yml @@ -46,7 +46,7 @@ jobs: - name: Run Tests and Build Test Artifacts if: github.event_name != 'workflow_dispatch' || env.HAS_SIGNING_KEY != 'true' - run: ./gradlew test assembleQaDebug assembleQaRelease assembleNormalDebug -P versionName=${{ steps.ghd.outputs.describe }} + 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' @@ -54,13 +54,11 @@ jobs: - name: Collect artifact name run: | - for variant in qa normal; do - for build_type in debug release; do - if compgen -G "push/build/outputs/apk/$variant/$build_type/*.apk" > /dev/null; then - artifact_name=$(basename -s .apk push/build/outputs/apk/$variant/$build_type/*.apk) - echo "${variant}_${build_type}_artifact=$artifact_name" >> $GITHUB_ENV - fi - 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 @@ -70,13 +68,6 @@ jobs: name: ${{ env.normal_release_artifact }} path: push/build/outputs/apk/normal/release/*.apk - - name: Upload Release For QA - if: env.qa_release_artifact != '' - uses: actions/upload-artifact@v4.6.0 - with: - name: ${{ env.qa_release_artifact }} - path: push/build/outputs/apk/qa/release/*.apk - - name: Upload Debug For Normal if: env.normal_debug_artifact != '' uses: actions/upload-artifact@v4.6.0 @@ -84,28 +75,25 @@ jobs: name: ${{ env.normal_debug_artifact }} path: push/build/outputs/apk/normal/debug/*.apk - - name: Upload Debug For QA - if: env.qa_debug_artifact != '' - uses: actions/upload-artifact@v4.6.0 - with: - name: ${{ env.qa_debug_artifact }} - path: push/build/outputs/apk/qa/debug/*.apk - - name: Get Version Name id: gvn run: | - if compgen -G "push/build/outputs/apk/*/*/*.apk" > /dev/null; then - artifact_name=(push/build/outputs/apk/*/*/*.apk) + 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' && steps.gvn.outputs.version != '' + 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/push/build.gradle b/push/build.gradle index 70f382624..e0716e70a 100644 --- a/push/build.gradle +++ b/push/build.gradle @@ -27,7 +27,6 @@ android { } buildConfigField "String", "GIT_TAG", "\"" + rootProject.ext.gitTag + "\"" - buildConfigField "boolean", "QA_BUILD", "false" manifestPlaceholders = [ mipushReceivePermission: "com.xiaomi.xmsf.permission.MIPUSH_RECEIVE" ] @@ -40,9 +39,6 @@ android { enableV3Signing = true enableV4Signing = true } - qa { - initWith(signingConfigs.debug) - } nihility { v1SigningEnabled true v2SigningEnabled true @@ -106,19 +102,6 @@ android { versionCode 1003003001 signingConfig signingConfigs.nihility } - qa { - dimension "version" - // Install beside a preloaded XMSF when its signing key is unavailable. - // This variant is intentionally limited to local UI/notification/perf QA. - applicationIdSuffix ".qa" - versionNameSuffix "-qa" - buildConfigField "boolean", "QA_BUILD", "true" - targetSdkVersion 34 - manifestPlaceholders = [ - mipushReceivePermission: "com.xiaomi.xmsf.qa.permission.MIPUSH_RECEIVE" - ] - signingConfig signingConfigs.qa - } vc105 { dimension "version" versionCode = 105 diff --git a/push/src/main/java/com/xiaomi/xmsf/MiPushFrameworkApp.java b/push/src/main/java/com/xiaomi/xmsf/MiPushFrameworkApp.java index 02557f937..0beff1f50 100644 --- a/push/src/main/java/com/xiaomi/xmsf/MiPushFrameworkApp.java +++ b/push/src/main/java/com/xiaomi/xmsf/MiPushFrameworkApp.java @@ -67,11 +67,9 @@ public void onCreate() { installCondom(); - // QA is intentionally isolated from real client discovery and transport. - // Production background work also follows the master switch so opening a - // disabled installation does not wake scanners or post keep-alive prompts. + // Follow the master switch so opening a disabled installation does not wake + // scanners or post keep-alive prompts. if (StartupWorkPolicy.shouldRunAppStartup( - BuildConfig.QA_BUILD, isAppMainProc(this), PushControllerUtils.isPrefsEnable(this))) { awakePushActivateService(PushControllerUtils.wrapContext(this)); 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 index 4c35ff8b5..4234c0129 100644 --- a/push/src/main/java/com/xiaomi/xmsf/push/control/StartupWorkPolicy.java +++ b/push/src/main/java/com/xiaomi/xmsf/push/control/StartupWorkPolicy.java @@ -6,8 +6,8 @@ private StartupWorkPolicy() { } public static boolean shouldRunAppStartup( - boolean qaBuild, boolean mainProcess, boolean masterEnabled) { - return !qaBuild && mainProcess && masterEnabled; + boolean mainProcess, boolean masterEnabled) { + return mainProcess && masterEnabled; } public static boolean shouldRunThrottled( 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 d5f56f5c8..9e21f3cc8 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 @@ -38,7 +38,6 @@ import com.xiaomi.push.service.MyNotificationIconHelper; import com.xiaomi.xmpush.thrift.PushMetaInfo; import com.xiaomi.xmpush.thrift.XmPushActionContainer; -import com.xiaomi.xmsf.BuildConfig; import com.xiaomi.xmsf.R; import com.xiaomi.xmsf.push.utils.Configurations; import com.xiaomi.xmsf.push.utils.IconConfigurations; @@ -379,7 +378,7 @@ private static boolean isFocusProtocolEnabled(Context context) { if (context == null) { return false; } - if (!BuildConfig.QA_BUILD && !"com.xiaomi.xmsf".equals(context.getPackageName())) { + if (!"com.xiaomi.xmsf".equals(context.getPackageName())) { return false; } int protocolVersion; 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 54a754aa0..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,7 +14,6 @@ import com.elvishew.xlog.Logger; import com.elvishew.xlog.XLog; -import com.xiaomi.xmsf.BuildConfig; import com.xiaomi.xmsf.push.control.PushControllerUtils; import java.util.ArrayList; @@ -39,8 +38,7 @@ public MiuiPushActivateService(String str) { } public static void awakePushActivateService(Context context, String str) { - if (context == null || BuildConfig.QA_BUILD - || !PushControllerUtils.isPrefsEnable(context)) { + if (context == null || !PushControllerUtils.isPrefsEnable(context)) { return; } try { @@ -92,7 +90,7 @@ public void addRegisteredPackage(String str, String str2) { } protected void onHandleIntent(Intent intent) { - if (BuildConfig.QA_BUILD || !PushControllerUtils.isPrefsEnable(this)) { + if (!PushControllerUtils.isPrefsEnable(this)) { return; } if ("com.xiaomi.xmsf.push.SCAN".equals(intent.getAction())) { diff --git a/push/src/main/java/top/trumeet/mipushframework/component/MiuixCompat.kt b/push/src/main/java/top/trumeet/mipushframework/component/MiuixCompat.kt index 2d50adfc6..23035e60f 100644 --- a/push/src/main/java/top/trumeet/mipushframework/component/MiuixCompat.kt +++ b/push/src/main/java/top/trumeet/mipushframework/component/MiuixCompat.kt @@ -1,22 +1,44 @@ package top.trumeet.mipushframework.component +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.spring 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.offset import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +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.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 @@ -135,19 +157,95 @@ fun MiuixBottomNavigation( floating: Boolean = true, ) { if (floating) { + require(items.size in 2..5) { "BottomBar must have between 2 and 5 items" } + val tabWidth = 76.dp + val indicatorOffset by animateDpAsState( + targetValue = tabWidth * selected.coerceIn(items.indices), + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + ), + label = "MiuixBottomNavigationIndicator", + ) + + // Miuix 0.2.x predates FloatingNavigationBar and the blur module used by current + // KernelSU. This is its deliberate non-blur compatibility path: the same intrinsic-width + // 64dp pill, equal 76dp tabs and one animated 56dp selection indicator. Surface( - modifier = modifier, - shape = SmoothRoundedCornerShape(28.dp), + modifier = modifier.wrapContentWidth(), + shape = SmoothRoundedCornerShape(32.dp), color = MiuixTheme.colorScheme.surfaceContainer, - shadowElevation = 12f, + shadowElevation = 1f, ) { - NavigationBar( - items = items, - selected = selected, - onClick = onClick, - color = Color.Transparent, - defaultWindowInsetsPadding = false, - ) + Box( + modifier = Modifier + .height(64.dp) + .width(tabWidth * items.size + 8.dp) + .padding(4.dp), + ) { + Surface( + modifier = Modifier + .offset(x = indicatorOffset) + .width(tabWidth) + .height(56.dp), + shape = SmoothRoundedCornerShape(28.dp), + color = MiuixTheme.colorScheme.primary.copy(alpha = 0.15f), + ) {} + + Row(modifier = Modifier.fillMaxSize()) { + items.forEachIndexed { index, item -> + val isSelected = index == selected + val contentColor by animateColorAsState( + targetValue = if (isSelected) { + MiuixTheme.colorScheme.primary + } else { + MiuixTheme.colorScheme.onSurfaceVariantActions + }, + label = "MiuixBottomNavigationContent", + ) + Surface( + onClick = { onClick(index) }, + modifier = Modifier + .width(tabWidth) + .height(56.dp) + .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( 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 ccec4e01b..fe888a7f9 100644 --- a/push/src/main/java/top/trumeet/mipushframework/main/MainPage.kt +++ b/push/src/main/java/top/trumeet/mipushframework/main/MainPage.kt @@ -256,12 +256,13 @@ private fun Main( modifier = Modifier .fillMaxWidth() .navigationBarsPadding() - .padding(horizontal = 18.dp, vertical = 10.dp), - contentAlignment = Alignment.Center, + // Miuix's floating navigation pattern keeps a small breathing room above + // the gesture bar; the bar itself owns its intrinsic width. + .padding(bottom = 12.dp), + contentAlignment = Alignment.BottomCenter, ) { BottomNavigationBar( navController = navController, - modifier = Modifier.fillMaxWidth(), floating = true, ) } diff --git a/push/src/qa/AndroidManifest.xml b/push/src/qa/AndroidManifest.xml deleted file mode 100644 index 6515b1d80..000000000 --- a/push/src/qa/AndroidManifest.xml +++ /dev/null @@ -1,101 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/push/src/test/java/com/xiaomi/xmsf/ManifestComponentContractTest.java b/push/src/test/java/com/xiaomi/xmsf/ManifestComponentContractTest.java index cf74392c9..f48b06c65 100644 --- a/push/src/test/java/com/xiaomi/xmsf/ManifestComponentContractTest.java +++ b/push/src/test/java/com/xiaomi/xmsf/ManifestComponentContractTest.java @@ -25,39 +25,24 @@ public class ManifestComponentContractTest { "com.xiaomi.mipush.sdk.PushMessageHandler"; @Test - public void sourceManifestsDeclareProductionAndQaBoundaries() throws Exception { + public void sourceManifestDeclaresProductionComponents() throws Exception { Path pushDirectory = findPushDirectory(); Document main = parse(pushDirectory.resolve("src/main/AndroidManifest.xml")); - Document qa = parse(pushDirectory.resolve("src/qa/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"); - - assertAttribute(qa, "receiver", PUSH_RECEIVER, "enabled", "false"); - assertAttribute(qa, "receiver", PUSH_RECEIVER, "exported", "false"); - assertAttribute(qa, "service", PUSH_SERVICE, "exported", "false"); - assertAttribute(qa, "service", PUSH_MESSAGE_HANDLER, "enabled", "false"); - assertAttribute(qa, "service", PUSH_MESSAGE_HANDLER, "exported", "false"); } @Test public void mergedManifestPreservesCurrentVariantContract() throws Exception { Document merged = parse(findMergedManifest()); - if (BuildConfig.QA_BUILD) { - assertAttribute(merged, "receiver", PUSH_RECEIVER, "enabled", "false"); - assertAttribute(merged, "receiver", PUSH_RECEIVER, "exported", "false"); - assertAttribute(merged, "service", PUSH_SERVICE, "exported", "false"); - assertAttribute(merged, "service", PUSH_MESSAGE_HANDLER, "enabled", "false"); - assertAttribute(merged, "service", PUSH_MESSAGE_HANDLER, "exported", "false"); - } else { - 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"); - } + 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() { diff --git a/push/src/test/java/com/xiaomi/xmsf/NormalVariantContractTest.java b/push/src/test/java/com/xiaomi/xmsf/NormalVariantContractTest.java index 0789a63a2..341c02fe5 100644 --- a/push/src/test/java/com/xiaomi/xmsf/NormalVariantContractTest.java +++ b/push/src/test/java/com/xiaomi/xmsf/NormalVariantContractTest.java @@ -1,7 +1,6 @@ package com.xiaomi.xmsf; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import org.junit.Test; @@ -10,16 +9,13 @@ public class NormalVariantContractTest { @Test public void testNormalVariantContract() { - if (!BuildConfig.QA_BUILD) { - assertFalse("QA_BUILD must be false for normal variant", BuildConfig.QA_BUILD); - assertEquals("Normal package name must be com.xiaomi.xmsf", "com.xiaomi.xmsf", BuildConfig.APPLICATION_ID); - } + assertEquals("Normal package name must be com.xiaomi.xmsf", "com.xiaomi.xmsf", BuildConfig.APPLICATION_ID); } @Test public void testVersionCodeContract() { - if (!BuildConfig.QA_BUILD && BuildConfig.VERSION_CODE > 0) { - // Normal variant version code contract (1003003001 or normal) + // 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/QaVariantContractTest.java b/push/src/test/java/com/xiaomi/xmsf/QaVariantContractTest.java deleted file mode 100644 index 167ae3240..000000000 --- a/push/src/test/java/com/xiaomi/xmsf/QaVariantContractTest.java +++ /dev/null @@ -1,26 +0,0 @@ -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; - -public class QaVariantContractTest { - - @Test - public void testQaVariantContract() { - // When running in QA variant, QA_BUILD must be true - if (BuildConfig.QA_BUILD) { - assertTrue("QA_BUILD must be true for qa variant", BuildConfig.QA_BUILD); - assertTrue("QA application ID must end with .qa or be qa variant", - BuildConfig.APPLICATION_ID.endsWith(".qa") || BuildConfig.BUILD_TYPE.equals("debug")); - } - } - - @Test - public void testGreenDaoSchemaVersionContract() { - // Schema version 17 must be preserved - assertNotNull("Package com.xiaomi.xmsf must exist", BuildConfig.APPLICATION_ID); - } -} diff --git a/push/src/test/java/com/xiaomi/xmsf/ReceiverDisabledTest.java b/push/src/test/java/com/xiaomi/xmsf/ReceiverDisabledTest.java deleted file mode 100644 index 009372988..000000000 --- a/push/src/test/java/com/xiaomi/xmsf/ReceiverDisabledTest.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.xiaomi.xmsf; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; - -import org.junit.Test; - -import java.util.Arrays; -import java.util.List; - -public class ReceiverDisabledTest { - - private static final List DISABLED_RECEIVERS_IN_QA = Arrays.asList( - "com.xiaomi.xmsf.push.service.receivers.BootReceiver", - "com.xiaomi.xmsf.push.service.receivers.NetworkStatusReceiver", - "com.xiaomi.xmsf.push.service.receivers.MiPushPingReceiver", - "com.xiaomi.xmsf.push.service.receivers.AccountChangedReceiver", - "com.xiaomi.xmsf.push.service.receivers.PkgUninstallReceiver", - "com.xiaomi.push.service.SelfUpdateReceiver", - "com.catchingnow.icebox.sdk_client.StateReceiver", - "com.xiaomi.xmsf.push.service.receivers.NotificationEventReceiver", - "com.xiaomi.push.revival.NotificationsRevivalForSelfUpdated" - ); - - @Test - public void testDisabledReceiversListComplete() { - assertEquals("Exactly 9 automatic receivers must be disabled in QA overlay", 9, DISABLED_RECEIVERS_IN_QA.size()); - for (String receiverClass : DISABLED_RECEIVERS_IN_QA) { - try { - Class clazz = Class.forName(receiverClass); - assertTrue("Class should be assignable to Object", Object.class.isAssignableFrom(clazz)); - } catch (ClassNotFoundException e) { - // If optional classes are not present in test classpath, class name is still verified - assertTrue(receiverClass.length() > 0); - } - } - } -} 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 index 37970c048..62f36717c 100644 --- a/push/src/test/java/com/xiaomi/xmsf/push/control/StartupWorkPolicyTest.java +++ b/push/src/test/java/com/xiaomi/xmsf/push/control/StartupWorkPolicyTest.java @@ -7,11 +7,11 @@ public class StartupWorkPolicyTest { @Test - public void qaAndDisabledInstallsNeverRunAutomaticStartupWork() { - assertFalse(StartupWorkPolicy.shouldRunAppStartup(true, true, true)); - assertFalse(StartupWorkPolicy.shouldRunAppStartup(false, true, false)); - assertFalse(StartupWorkPolicy.shouldRunAppStartup(false, false, true)); - assertTrue(StartupWorkPolicy.shouldRunAppStartup(false, true, true)); + public void disabledOrNonMainProcessesNeverRunAutomaticStartupWork() { + assertFalse(StartupWorkPolicy.shouldRunAppStartup(true, false)); + assertFalse(StartupWorkPolicy.shouldRunAppStartup(false, true)); + assertFalse(StartupWorkPolicy.shouldRunAppStartup(false, false)); + assertTrue(StartupWorkPolicy.shouldRunAppStartup(true, true)); } @Test From 12b4c32927aa5a14d0f7334f5293b0697c5f57f1 Mon Sep 17 00:00:00 2001 From: SherlockChiang Date: Mon, 17 Aug 2026 17:50:23 +0800 Subject: [PATCH 06/64] feat: match KernelSU floating navigation interactions --- .../mipushframework/component/MiuixCompat.kt | 172 +++++++++++++++--- .../trumeet/mipushframework/main/MainPage.kt | 40 ++-- 2 files changed, 174 insertions(+), 38 deletions(-) diff --git a/push/src/main/java/top/trumeet/mipushframework/component/MiuixCompat.kt b/push/src/main/java/top/trumeet/mipushframework/component/MiuixCompat.kt index 23035e60f..eb462e13d 100644 --- a/push/src/main/java/top/trumeet/mipushframework/component/MiuixCompat.kt +++ b/push/src/main/java/top/trumeet/mipushframework/component/MiuixCompat.kt @@ -1,9 +1,15 @@ package top.trumeet.mipushframework.component -import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.EaseOut import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.animateDpAsState +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 @@ -14,22 +20,33 @@ 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.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentWidth +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 @@ -44,6 +61,9 @@ 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. @@ -159,20 +179,80 @@ fun MiuixBottomNavigation( if (floating) { require(items.size in 2..5) { "BottomBar must have between 2 and 5 items" } val tabWidth = 76.dp - val indicatorOffset by animateDpAsState( - targetValue = tabWidth * selected.coerceIn(items.indices), + 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 = Spring.DampingRatioNoBouncy, - stiffness = Spring.StiffnessMediumLow, + dampingRatio = 0.5f, + stiffness = 300f, ), - label = "MiuixBottomNavigationIndicator", + 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 + // Miuix 0.2.x predates FloatingNavigationBar and the blur module used by current - // KernelSU. This is its deliberate non-blur compatibility path: the same intrinsic-width - // 64dp pill, equal 76dp tabs and one animated 56dp selection indicator. + // KernelSU. This compatibility path preserves its 64/4/56/76dp geometry, draggable + // indicator, RTL-aware motion and edge resistance without importing the newer blur stack. Surface( - modifier = modifier.wrapContentWidth(), + modifier = modifier + .wrapContentWidth() + .graphicsLayer { translationX = panelOffsetPx }, shape = SmoothRoundedCornerShape(32.dp), color = MiuixTheme.colorScheme.surfaceContainer, shadowElevation = 1f, @@ -182,32 +262,78 @@ fun MiuixBottomNavigation( .height(64.dp) .width(tabWidth * items.size + 8.dp) .padding(4.dp), + contentAlignment = Alignment.CenterStart, ) { Surface( modifier = Modifier - .offset(x = indicatorOffset) + .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()) { + Row( + modifier = Modifier + .fillMaxSize() + .selectableGroup(), + ) { items.forEachIndexed { index, item -> - val isSelected = index == selected - val contentColor by animateColorAsState( - targetValue = if (isSelected) { - MiuixTheme.colorScheme.primary - } else { - MiuixTheme.colorScheme.onSurfaceVariantActions - }, - label = "MiuixBottomNavigationContent", - ) + 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( - onClick = { onClick(index) }, 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 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 fe888a7f9..2171a6990 100644 --- a/push/src/main/java/top/trumeet/mipushframework/main/MainPage.kt +++ b/push/src/main/java/top/trumeet/mipushframework/main/MainPage.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue @@ -209,6 +210,7 @@ fun BottomNavigationBar( navController: NavController, modifier: Modifier = Modifier, floating: Boolean = true, + initialRoute: String? = null, ) { val items = listOf( Screen.Events, Screen.Apps, Screen.Settings @@ -222,22 +224,28 @@ fun BottomNavigationBar( icon = screen.icon, ) } - val selected = items.indexOfFirst { it.route.toString() == currentRoute }.coerceAtLeast(0) + val selectedRoute = currentRoute ?: initialRoute + val selected = items.indexOfFirst { it.route.toString() == selectedRoute }.coerceAtLeast(0) - 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, - ) + // 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 @@ -264,6 +272,7 @@ private fun Main( BottomNavigationBar( navController = navController, floating = true, + initialRoute = startDestination, ) } } else { @@ -271,6 +280,7 @@ private fun Main( navController = navController, modifier = Modifier.fillMaxWidth(), floating = false, + initialRoute = startDestination, ) } }, From 517612d939c916b5cbb50eb54c6688d0e97c7be8 Mon Sep 17 00:00:00 2001 From: SherlockChiang Date: Mon, 17 Aug 2026 20:25:42 +0800 Subject: [PATCH 07/64] feat: deepen HyperOS notifications and idle recovery --- .../common/utils/CustomConfiguration.java | 52 ++++++- .../utils/utils/CustomConfigurationTest.java | 67 +++++++-- .../service/MyMIPushNotificationHelper.java | 142 ++++++++++++------ .../push/control/PushServiceDispatcher.java | 11 ++ .../push/control/PushServiceStartPolicy.java | 8 +- .../FocusProtocolSupportCache.java | 51 +++++++ .../notification/NotificationController.java | 43 ++++-- .../service/receivers/KeepAliveReceiver.java | 9 +- .../service/receivers/MiPushPingReceiver.java | 4 +- .../receivers/NetworkStatusReceiver.java | 62 +++++++- .../service/NotificationExecutorTest.java | 18 +++ .../control/PushServiceStartPolicyTest.java | 52 ++++--- .../FocusProtocolSupportCacheTest.java | 51 +++++++ .../receivers/KeepAliveReceiverTest.java | 6 +- .../receivers/NetworkStatusReceiverTest.java | 62 ++++++++ 15 files changed, 529 insertions(+), 109 deletions(-) create mode 100644 push/src/main/java/com/xiaomi/xmsf/push/notification/FocusProtocolSupportCache.java create mode 100644 push/src/test/java/com/xiaomi/xmsf/push/notification/FocusProtocolSupportCacheTest.java create mode 100644 push/src/test/java/com/xiaomi/xmsf/push/service/receivers/NetworkStatusReceiverTest.java 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 8273219f7..d662aa6e3 100644 --- a/common/src/main/java/top/trumeet/common/utils/CustomConfiguration.java +++ b/common/src/main/java/top/trumeet/common/utils/CustomConfiguration.java @@ -51,6 +51,9 @@ private static String Config(String name) { 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"; @@ -187,6 +190,10 @@ 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); } @@ -203,10 +210,41 @@ 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 get(NOTIFICATION_COLORFUL_BUTTON_BG_IMAGE_URI, 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); } @@ -260,7 +298,8 @@ public String focusParam(String defaultValue) { /** * Parse the documented, public part of Xiaomi's focus-notification payload. - * Invalid or over-limit data is left out instead of being forwarded to SystemUI. + * 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); @@ -271,9 +310,7 @@ public FocusNotificationPayload focusNotificationPayload() { List> pictureEntries = new ArrayList<>(); for (Map.Entry entry : mExtra.entrySet()) { String key = entry.getKey(); - String value = entry.getValue(); - if (key != null && key.startsWith(FOCUS_PICTURE_PREFIX) - && isSupportedPictureValue(value)) { + if (key != null && key.startsWith(FOCUS_PICTURE_PREFIX)) { pictureEntries.add(entry); } } @@ -413,15 +450,14 @@ public Map pictureUrls() { /** URLs selected for native Icon downloads; the URL payload remains complete. */ public Map downloadPictureUrls() { - if (pictureUrls.size() <= FOCUS_PICTURE_MAX_COUNT) { - return pictureUrls; - } Map result = new LinkedHashMap<>(); for (Map.Entry entry : pictureUrls.entrySet()) { if (result.size() >= FOCUS_PICTURE_MAX_COUNT) { break; } - result.put(entry.getKey(), entry.getValue()); + if (isSupportedPictureValue(entry.getValue())) { + result.put(entry.getKey(), entry.getValue()); + } } return Collections.unmodifiableMap(result); } 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 93b5e7cf1..28a1864cf 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 @@ -42,7 +42,7 @@ public void focusPayloadForwardsAllPicturesButCapsNativeDownloads() { assertTrue(payload.isUsable()); assertEquals("{\"ticker\":\"parcel\"}", payload.parameter()); - assertEquals(12, + assertEquals(14, payload.pictureUrls().size()); assertEquals("https://example.com/0.png", payload.pictureUrls().get("miui.focus.pic_0")); @@ -52,14 +52,16 @@ public void focusPayloadForwardsAllPicturesButCapsNativeDownloads() { "miui.focus.pic_6", "miui.focus.pic_7", "miui.focus.pic_8", "miui.focus.pic_9"), new ArrayList<>(payload.downloadPictureUrls().keySet())); - assertFalse(payload.pictureUrls().containsKey("miui.focus.pic_http")); - assertFalse(payload.pictureUrls().containsKey("miui.focus.pic_malformed")); + 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 focusPayloadRejectsUrlsWithUserInfoEmptyHostAndWhitespace() { + 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"); @@ -73,14 +75,20 @@ public void focusPayloadRejectsUrlsWithUserInfoEmptyHostAndWhitespace() { new CustomConfiguration(extras).focusNotificationPayload(); assertTrue(payload.isUsable()); - assertEquals(1, payload.pictureUrls().size()); + 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")); - assertFalse(payload.pictureUrls().containsKey("miui.focus.pic_1")); - assertFalse(payload.pictureUrls().containsKey("miui.focus.pic_2")); - assertFalse(payload.pictureUrls().containsKey("miui.focus.pic_3")); - assertFalse(payload.pictureUrls().containsKey("miui.focus.pic_4")); - assertFalse(payload.pictureUrls().containsKey("miui.focus.pic_5")); + 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 @@ -225,6 +233,45 @@ public void officialHyperOsNotificationMetadataUsesPublishedKeys() { 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.put("notification_colorful_bg_image_uri", "content://official/image"); + assertEquals("content://official/image", + custom.notificationColorfulBackgroundImageUri(null)); + + extras.put("notification_colorful_bg_image_uri", ""); + assertEquals("", custom.notificationColorfulBackgroundImageUri(null)); + } + @Test public void resourceSoundRequiresSoundBitAndMatchingPackage() { String packageName = "com.example.app"; 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 58d85287b..633b5f595 100644 --- a/push/src/main/java/com/xiaomi/push/service/MyMIPushNotificationHelper.java +++ b/push/src/main/java/com/xiaomi/push/service/MyMIPushNotificationHelper.java @@ -3,6 +3,7 @@ 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; @@ -88,9 +89,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"; @@ -107,6 +111,26 @@ 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"; + 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; @@ -342,18 +366,30 @@ private static NotificationCompat.Builder normalStyleNotificationBuilder(Context String title = metaInfo.getTitle(); String description = metaInfo.getDescription(); CustomConfiguration configuration = XMPushUtils.getConfiguration(metaInfo); - String styleType = configuration.notificationStyleType(null); + CustomConfiguration.NotificationStyle notificationStyle = + configuration.notificationStyle(); Bitmap bigPic = getBigPic(context, metaInfo); - if ("3".equals(styleType)) { + if (notificationStyle == CustomConfiguration.NotificationStyle.COLORFUL) { bigPic = getBitmapFromUri(context, - configuration.notificationBannerImageUri(null), 1 * MiB); - } else if ("4".equals(styleType)) { + configuration.notificationColorfulBackgroundImageUri(null), 1 * MiB); + } else if (notificationStyle == CustomConfiguration.NotificationStyle.BANNER) { bigPic = getBitmapFromUri(context, - configuration.notificationColorfulButtonBackgroundImageUri(null), 1 * MiB); + 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); @@ -363,7 +399,7 @@ private static NotificationCompat.Builder normalStyleNotificationBuilder(Context style.setContentDescription(imageDescription); } notificationBuilder.setStyle(style); - } else if ("1".equals(styleType) + } else if (notificationStyle == CustomConfiguration.NotificationStyle.BIG_TEXT || description.length() > NOTIFICATION_BIG_STYLE_MIN_LEN) { NotificationCompat.BigTextStyle style = new NotificationCompat.BigTextStyle(); style.bigText(description); @@ -371,16 +407,6 @@ private static NotificationCompat.Builder normalStyleNotificationBuilder(Context notificationBuilder.setStyle(style); } - if ("4".equals(styleType)) { - String background = configuration.notificationColorfulButtonBackgroundColor(null); - if (background != null) { - try { - notificationBuilder.setColor(Color.parseColor(background)); - } catch (IllegalArgumentException ignored) { - } - } - } - String[] titleAndDesp = determineTitleAndDespByDIP(context, metaInfo); notificationBuilder.setContentTitle(titleAndDesp[0]); notificationBuilder.setContentText(titleAndDesp[1]); @@ -830,6 +856,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; } @@ -838,20 +874,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, PendingIntent.FLAG_IMMUTABLE); + 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; } @@ -863,18 +892,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); @@ -883,18 +902,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://")) { @@ -926,5 +940,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/xmsf/push/control/PushServiceDispatcher.java b/push/src/main/java/com/xiaomi/xmsf/push/control/PushServiceDispatcher.java index c88891efb..91c53eacc 100644 --- a/push/src/main/java/com/xiaomi/xmsf/push/control/PushServiceDispatcher.java +++ b/push/src/main/java/com/xiaomi/xmsf/push/control/PushServiceDispatcher.java @@ -25,6 +25,17 @@ public static PushServiceStartPolicy.Action dispatchStart(Context context, boole 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. 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 index 78d488e5f..72e50d4fa 100644 --- a/push/src/main/java/com/xiaomi/xmsf/push/control/PushServiceStartPolicy.java +++ b/push/src/main/java/com/xiaomi/xmsf/push/control/PushServiceStartPolicy.java @@ -26,9 +26,11 @@ public static Action evaluate( if (isUserInitiated) { return Action.START_SERVICE; } - if (isPersistentForegroundEnabled && isPlatformAllowed) { - return Action.START_FOREGROUND; + if (!isPlatformAllowed) { + return Action.SKIP; } - return Action.SKIP; + return isPersistentForegroundEnabled + ? Action.START_FOREGROUND + : Action.START_SERVICE; } } 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/NotificationController.java b/push/src/main/java/com/xiaomi/xmsf/push/notification/NotificationController.java index 9e21f3cc8..6ffb227ba 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 @@ -18,6 +18,7 @@ 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; @@ -79,6 +80,9 @@ public class NotificationController { private static final String FOCUS_PROTOCOL_SETTING = "notification_focus_protocol"; private static final String FOCUS_PARAM = "miui.focus.param"; 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); // 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. @@ -256,6 +260,23 @@ private static void applyOfficialMetadata( 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); @@ -381,16 +402,18 @@ private static boolean isFocusProtocolEnabled(Context context) { if (!"com.xiaomi.xmsf".equals(context.getPackageName())) { return false; } - int protocolVersion; - try { - protocolVersion = Settings.System.getInt(context.getContentResolver(), - FOCUS_PROTOCOL_SETTING, 0); - } catch (Throwable error) { - logger.w("Unable to read focus-notification protocol setting", error); - return false; - } - return CustomConfiguration.FocusNotificationPayload - .isSupportedProtocolVersion(protocolVersion); + return FOCUS_PROTOCOL_SUPPORT_CACHE.get(SystemClock.elapsedRealtime(), () -> { + int protocolVersion; + try { + protocolVersion = Settings.System.getInt(context.getContentResolver(), + FOCUS_PROTOCOL_SETTING, 0); + } catch (Throwable error) { + logger.w("Unable to read focus-notification protocol setting", error); + return false; + } + return CustomConfiguration.FocusNotificationPayload + .isSupportedProtocolVersion(protocolVersion); + }); } @RequiresApi(Build.VERSION_CODES.M) 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 a51363200..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 @@ -8,6 +8,7 @@ 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; @@ -34,7 +35,8 @@ public void onReceive(Context context, Intent intent) { } // 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 (PushControllerUtils.isPushServiceRunning()) { + if (!shouldAttemptRecoveryForServiceState( + PushControllerUtils.isPushServiceRunning())) { return; } try { @@ -46,7 +48,8 @@ public void onReceive(Context context, Intent intent) { lastActiveElapsedRealtime = nowElapsedRealtime; logger.d("start service when " + intent.getAction()); - PushServiceDispatcher.dispatchStart(context, false); + PushServiceDispatcher.dispatchStart( + context, PushServiceConstants.ACTION_CHECK_ALIVE, false); } catch (Exception localException) { MyLog.e(localException); } @@ -57,7 +60,7 @@ static boolean shouldStart(long lastElapsedRealtime, long nowElapsedRealtime) { || nowElapsedRealtime - lastElapsedRealtime >= MIN_START_INTERVAL_MS; } - static boolean shouldUseForegroundStart(boolean serviceRunning) { + 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 95ea3edc9..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 @@ -7,6 +7,7 @@ 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; @@ -23,7 +24,8 @@ public void onReceive(Context paramContext, Intent paramIntent) { if (PushConstants.ACTION_PING_TIMER.equals(paramIntent.getAction())) { if (TextUtils.equals(paramContext.getPackageName(), paramIntent.getPackage())) { MyLog.v("Ping XMChannelService on timer"); - PushServiceDispatcher.dispatchStart(paramContext, false); + 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 7bde6b306..af37bc4fe 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,6 +3,7 @@ import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; +import android.os.SystemClock; import com.xiaomi.channel.commonutils.network.Network; import com.xiaomi.mipush.sdk.PushServiceClient; @@ -10,21 +11,76 @@ 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; + static final long NO_RECOVERY_ATTEMPT = Long.MIN_VALUE; + + private static final AtomicLong LAST_RECOVERY_ELAPSED_REALTIME = + new AtomicLong(NO_RECOVERY_ATTEMPT); + public void onReceive(Context context, Intent intent) { if (context == null || !PushControllerUtils.isPrefsEnable(context)) { return; } - PushServiceDispatcher.dispatchStart(context, false); + + 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 (Network.hasNetwork(context) && PushServiceClient.getInstance(context).isProvisioned()) { - PushServiceClient.getInstance(context).processRegisterTask(); + if (hasNetwork) { + PushServiceClient client = PushServiceClient.getInstance(context); + if (client.isProvisioned()) { + client.processRegisterTask(); + } } } catch (Throwable ignored) { } } + + 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/test/java/com/xiaomi/push/service/NotificationExecutorTest.java b/push/src/test/java/com/xiaomi/push/service/NotificationExecutorTest.java index e5a7f73f9..2728afc35 100644 --- a/push/src/test/java/com/xiaomi/push/service/NotificationExecutorTest.java +++ b/push/src/test/java/com/xiaomi/push/service/NotificationExecutorTest.java @@ -45,4 +45,22 @@ public void testThreadFactoryNaming() { assertTrue("Thread name must start with mipush-notification-", thread.getName().startsWith("mipush-notification-")); } + + @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); + } } 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 index 69aec26b3..8db28a538 100644 --- a/push/src/test/java/com/xiaomi/xmsf/push/control/PushServiceStartPolicyTest.java +++ b/push/src/test/java/com/xiaomi/xmsf/push/control/PushServiceStartPolicyTest.java @@ -7,41 +7,57 @@ public class PushServiceStartPolicyTest { @Test - public void masterDisabledReturnsSkip() { - PushServiceStartPolicy.Action action = PushServiceStartPolicy.evaluate( + public void masterDisabledAlwaysReturnsSkip() { + assertAction(PushServiceStartPolicy.Action.SKIP, false, true, true, true, true); - assertEquals(PushServiceStartPolicy.Action.SKIP, action); + assertAction(PushServiceStartPolicy.Action.SKIP, + false, false, false, false, true); } @Test - public void serviceRunningReturnsStartService() { - PushServiceStartPolicy.Action action = PushServiceStartPolicy.evaluate( + public void serviceRunningWithMasterEnabledReturnsStartService() { + assertAction(PushServiceStartPolicy.Action.START_SERVICE, true, true, false, false, false); - assertEquals(PushServiceStartPolicy.Action.START_SERVICE, action); + assertAction(PushServiceStartPolicy.Action.START_SERVICE, + true, true, false, true, true); } @Test - public void userInitiatedAndServiceDeadReturnsStartService() { - PushServiceStartPolicy.Action action = PushServiceStartPolicy.evaluate( + public void userInitiatedWithMasterEnabledReturnsStartService() { + assertAction(PushServiceStartPolicy.Action.START_SERVICE, true, false, true, false, false); - assertEquals(PushServiceStartPolicy.Action.START_SERVICE, action); + assertAction(PushServiceStartPolicy.Action.START_SERVICE, + true, false, true, true, true); } @Test - public void backgroundWithPersistentForegroundAndPlatformAllowedReturnsStartForeground() { - PushServiceStartPolicy.Action action = PushServiceStartPolicy.evaluate( + public void backgroundStartUsesConfiguredModeWhenPlatformAllowed() { + assertAction(PushServiceStartPolicy.Action.START_FOREGROUND, true, false, false, true, true); - assertEquals(PushServiceStartPolicy.Action.START_FOREGROUND, action); + assertAction(PushServiceStartPolicy.Action.START_SERVICE, + true, false, false, false, true); } @Test - public void backgroundNotAllowedReturnsSkip() { - PushServiceStartPolicy.Action action = PushServiceStartPolicy.evaluate( + public void backgroundStartReturnsSkipWhenPlatformNotAllowed() { + assertAction(PushServiceStartPolicy.Action.SKIP, true, false, false, true, false); - assertEquals(PushServiceStartPolicy.Action.SKIP, action); + assertAction(PushServiceStartPolicy.Action.SKIP, + true, false, false, false, false); + } - PushServiceStartPolicy.Action actionNoForeground = PushServiceStartPolicy.evaluate( - true, false, false, false, true); - assertEquals(PushServiceStartPolicy.Action.SKIP, actionNoForeground); + 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/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 index 437d76ba9..9671f3639 100644 --- 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 @@ -24,8 +24,8 @@ public void permitsRecoveryAtIntervalBoundary() { } @Test - public void runningServiceDoesNotRequireAnotherForegroundStart() { - assertFalse(KeepAliveReceiver.shouldUseForegroundStart(true)); - assertTrue(KeepAliveReceiver.shouldUseForegroundStart(false)); + 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..bdf9afcda --- /dev/null +++ b/push/src/test/java/com/xiaomi/xmsf/push/service/receivers/NetworkStatusReceiverTest.java @@ -0,0 +1,62 @@ +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)); + } +} From 81e8d2973a61f1663df3c7897a45048413a797b3 Mon Sep 17 00:00:00 2001 From: SherlockChiang Date: Mon, 17 Aug 2026 21:41:46 +0800 Subject: [PATCH 08/64] fix: keep colorful image aliases consistent --- .../java/top/trumeet/common/utils/CustomConfiguration.java | 2 +- .../trumeet/common/utils/utils/CustomConfigurationTest.java | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) 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 d662aa6e3..05ca9a203 100644 --- a/common/src/main/java/top/trumeet/common/utils/CustomConfiguration.java +++ b/common/src/main/java/top/trumeet/common/utils/CustomConfiguration.java @@ -225,7 +225,7 @@ public String notificationColorfulBackgroundImageUri(String defaultValue) { } public String notificationColorfulButtonBackgroundImageUri(String defaultValue) { - return get(NOTIFICATION_COLORFUL_BUTTON_BG_IMAGE_URI, defaultValue); + return notificationColorfulBackgroundImageUri(defaultValue); } public enum NotificationStyle { 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 28a1864cf..071453287 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 @@ -264,9 +264,15 @@ public void officialColorfulBackgroundKeysWinOverLegacyImageFallback() { 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)); From 72c473873b412d206a41ae4c662d92d7f6767a06 Mon Sep 17 00:00:00 2001 From: SherlockChiang Date: Tue, 18 Aug 2026 19:18:55 +0800 Subject: [PATCH 09/64] feat: harden focus notifications and explicit registration UI --- .../common/utils/CustomConfiguration.java | 3 +- .../utils/utils/CustomConfigurationTest.java | 13 + .../service/MyMIPushNotificationHelper.java | 56 +++- .../notification/FocusNotificationSafety.java | 247 ++++++++++++++++++ .../notification/NotificationController.java | 216 ++++++++++++++- .../main/ApplicationInfoPage.kt | 175 +++++++++---- push/src/main/res/values-zh/strings.xml | 3 + push/src/main/res/values/strings.xml | 3 + .../FocusNotificationSafetyTest.java | 197 ++++++++++++++ ...plicationInfoPageRegistrationActionTest.kt | 30 +++ 10 files changed, 869 insertions(+), 74 deletions(-) create mode 100644 push/src/main/java/com/xiaomi/xmsf/push/notification/FocusNotificationSafety.java create mode 100644 push/src/test/java/com/xiaomi/xmsf/push/notification/FocusNotificationSafetyTest.java create mode 100644 push/src/test/java/top/trumeet/mipushframework/main/ApplicationInfoPageRegistrationActionTest.kt 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 05ca9a203..8920ca87e 100644 --- a/common/src/main/java/top/trumeet/common/utils/CustomConfiguration.java +++ b/common/src/main/java/top/trumeet/common/utils/CustomConfiguration.java @@ -463,7 +463,8 @@ public Map downloadPictureUrls() { } public boolean isUsable() { - return parameter != null || !pictureUrls.isEmpty(); + return (parameter != null && !parameter.trim().isEmpty()) + || !pictureUrls.isEmpty(); } public static boolean isSupportedProtocolVersion(int version) { 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 071453287..93464f369 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 @@ -128,6 +128,19 @@ public void focusPayloadAcceptsParameterWithoutPictures() { assertTrue(payload.pictureUrls().isEmpty()); } + @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(); 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 633b5f595..d7459017e 100644 --- a/push/src/main/java/com/xiaomi/push/service/MyMIPushNotificationHelper.java +++ b/push/src/main/java/com/xiaomi/push/service/MyMIPushNotificationHelper.java @@ -49,6 +49,7 @@ import com.xiaomi.xmpush.thrift.PushMetaInfo; 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; @@ -312,7 +313,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, container.getPackageName(), container.getMetaInfo()); } if (metaInfo.getExtra() != null) { @@ -362,10 +364,24 @@ private static Context getPackageContext(Context context, String packageName) { } @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(); @@ -408,8 +424,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; } @@ -590,15 +610,29 @@ private static String getGroupName(Context xmPushService, XmPushActionContainer 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()) + || !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) { 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..99882994f --- /dev/null +++ b/push/src/main/java/com/xiaomi/xmsf/push/notification/FocusNotificationSafety.java @@ -0,0 +1,247 @@ +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."; + public static final int MAX_PARAMETER_BYTES = 3_072; + public static final long IMAGE_ENRICHMENT_BUDGET_MILLIS = 700L; + + private static final int MAX_FALLBACK_TITLE_CODE_POINTS = 160; + private static final int MAX_FALLBACK_BODY_CODE_POINTS = 1_024; + 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) { + String focusTitle = null; + String focusTicker = null; + String focusBody = null; + if (isParameterWithinLimit(focusParameter)) { + try { + JsonElement root = JsonParser.parseString(focusParameter); + if (root.isJsonObject()) { + JsonObject object = root.getAsJsonObject(); + focusTitle = boundedString(object, "title", + MAX_FALLBACK_TITLE_CODE_POINTS); + focusTicker = boundedString(object, "ticker", + MAX_FALLBACK_TITLE_CODE_POINTS); + focusBody = boundedString(object, "description", + MAX_FALLBACK_BODY_CODE_POINTS); + } + } catch (Throwable ignored) { + // The ordinary notification remains authoritative. + } + } + + String resolvedTitle = hasText(title) + ? title + : firstText(focusTitle, focusTicker, focusBody, + sanitizeFallback(fallbackTitle, MAX_FALLBACK_TITLE_CODE_POINTS), + DEFAULT_TITLE); + String resolvedBody = hasText(body) + ? body + : firstText(focusBody, focusTitle, focusTicker, + sanitizeFallback(fallbackBody, MAX_FALLBACK_BODY_CODE_POINTS), + DEFAULT_BODY); + return new ResolvedContent(resolvedTitle, resolvedBody); + } + + 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; + } + } + + 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) { + JsonElement value = object.get(name); + if (value == null || !value.isJsonPrimitive() + || !value.getAsJsonPrimitive().isString()) { + return null; + } + return sanitizeFallback(value.getAsString(), maxCodePoints); + } + + 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; + } + } +} 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 6ffb227ba..b429076f0 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 @@ -86,8 +86,6 @@ public class NotificationController { // 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. - private static final long FOCUS_DOWNLOAD_CALLER_BUDGET_MILLIS = 5_000L; - public static final String CHANNEL_WARN = "warn"; public static NotificationManagerEx getNotificationManagerEx() { @@ -115,7 +113,8 @@ private static void updateSummaryNotification(Context context, PushMetaInfo meta // 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, builder, metaInfo, false); + notify(context, groupId.hashCode(), packageName, getNotificationTag(packageName), + builder, metaInfo, false, false); } @RequiresApi(api = Build.VERSION_CODES.M) @@ -154,12 +153,65 @@ public static void publish(Context context, PushMetaInfo metaInfo, int notificat applyAlertBehavior(metaInfo, packageName, notificationBuilder); notificationBuilder.setPriority(Notification.PRIORITY_HIGH); - Notification notification = notify(context, notificationId, packageName, - notificationBuilder, metaInfo, true); + boolean attemptFocus = shouldAttachFocusExtras(context, metaInfo); + if (attemptFocus) { + // 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); + } + return notify(context, deliveryId, deliveryPackage, deliveryTag, + notificationBuilder, metaInfo, true, includeFocusExtras); + }); updateSummaryNotification(context, metaInfo, packageName, notification.getGroup()); } + 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); @@ -175,8 +227,9 @@ public static String getExistsChannelId(Context context, PushMetaInfo metaInfo, private static Notification notify( Context context, int notificationId, String packageName, - NotificationCompat.Builder notificationBuilder, PushMetaInfo metaInfo, - boolean includeFocusExtras) { + String notificationTag, NotificationCompat.Builder notificationBuilder, + PushMetaInfo metaInfo, + boolean includeOfficialMetadata, boolean includeFocusExtras) { // Make the behavior consistent with official MIUI Bundle extras = new Bundle(); extras.putString("target_package", packageName); @@ -185,8 +238,9 @@ private static Notification notify( // Set small icon processIcon(context, packageName, notificationBuilder); - if (includeFocusExtras) { - CustomConfiguration configuration = XMPushUtils.getConfiguration(metaInfo); + 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); @@ -197,6 +251,12 @@ private static Notification notify( String subText = configuration.subText(null); buildExtraSubText(context, packageName, notificationBuilder, subText); + } + + ensureReadableStandardContent(context, packageName, notificationBuilder, + metaInfo, configuration); + + if (includeFocusExtras && configuration != null) { addFocusNotificationExtras(context, notificationBuilder, configuration); } @@ -204,10 +264,122 @@ private static Notification notify( Notification notification = notificationBuilder.build(); applyTargetPackage(context, notification, packageName); getNotificationManagerEx().notify( - packageName, getNotificationTag(packageName), notificationId, notification); + packageName, notificationTag, notificationId, notification); return notification; } + private static boolean shouldAttachFocusExtras(Context context, PushMetaInfo metaInfo) { + try { + CustomConfiguration configuration = XMPushUtils.getConfiguration(metaInfo); + CustomConfiguration.FocusNotificationPayload payload = + configuration.focusNotificationPayload(); + if (!payload.isUsable() || !isFocusProtocolEnabled(context)) { + return false; + } + // 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()) + || !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 void 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()); + } + } + + @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, @@ -382,7 +554,9 @@ private static void addFocusNotificationExtras( } Bundle focusBundle = new Bundle(); - focusBundle.putString(FOCUS_PARAM, payload.parameter()); + if (FocusNotificationSafety.isWellFormedParameter(payload.parameter())) { + focusBundle.putString(FOCUS_PARAM, payload.parameter()); + } for (Map.Entry picture : payload.pictureUrls().entrySet()) { // Supported MIUI SystemUI needs both the URL and the native Icon. focusBundle.putString(picture.getKey(), picture.getValue()); @@ -460,7 +634,8 @@ static Bundle downloadPictures( Bundle result = new Bundle(); long deadlineNanos = System.nanoTime() - + TimeUnit.MILLISECONDS.toNanos(FOCUS_DOWNLOAD_CALLER_BUDGET_MILLIS); + + TimeUnit.MILLISECONDS.toNanos( + FocusNotificationSafety.IMAGE_ENRICHMENT_BUDGET_MILLIS); for (int i = 0; i < pictures.size(); i++) { Bitmap bitmap = null; long remainingNanos = deadlineNanos - System.nanoTime(); @@ -471,13 +646,26 @@ static Bundle downloadPictures( 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); } } - Icon icon = bitmap == null || bitmap.isRecycled() - ? null : Icon.createWithBitmap(bitmap); // 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; } 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 8782fc57f..a220b905f 100644 --- a/push/src/main/java/top/trumeet/mipushframework/main/ApplicationInfoPage.kt +++ b/push/src/main/java/top/trumeet/mipushframework/main/ApplicationInfoPage.kt @@ -18,7 +18,10 @@ 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 @@ -29,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 @@ -53,11 +59,25 @@ 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 { const val EXTRA_PACKAGE_NAME: String = "EXTRA_PACKAGE_NAME" @@ -137,44 +157,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), ) { - MiuixActionIconButton(onClick = { - RegistrationHelper( - context, - applicationInfo.packageName - ).deleteRegistrationInfoAndRetryForceRegister() - }) { - Image(icon, "Application Icon") - } - Column(Modifier.weight(1f)) { - Text( - applicationInfo.appName, - style = MiuixTheme.textStyles.body2 - ) - Text( - applicationInfo.packageName, - style = MiuixTheme.textStyles.footnote1 - ) - } - 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) - ) - }) { + 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, + ) + } } } } @@ -190,20 +230,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 } } @@ -293,7 +352,13 @@ class ApplicationInfoPage : ComponentActivity() { } @Composable -fun Tips(title: String, description: String) { +fun Tips( + title: String, + description: String, + actionDescription: String? = null, + actionLabel: String? = null, + onAction: (() -> Unit)? = null, +) { Row(modifier = Modifier.padding(10.dp)) { Icon( painterResource(R.drawable.ic_error_outline_black_24dp), null, @@ -307,6 +372,20 @@ fun Tips(title: String, description: String) { 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, + ) + } } } } diff --git a/push/src/main/res/values-zh/strings.xml b/push/src/main/res/values-zh/strings.xml index ef4d4733f..8b3a02297 100644 --- a/push/src/main/res/values-zh/strings.xml +++ b/push/src/main/res/values-zh/strings.xml @@ -232,6 +232,9 @@ 已忽略 %1$s 个无 MiPush SDK 的应用 应用程式注册问题解决方案[帮助] 应用注册问题。]]> Magisk 全局伪装模块Magisk 单应用伪装模块 以增加成功率。另见:应用程式注册问题解决方案[帮助] 应用注册问题。]]> + 立即注册 + 重新注册 + 这会清除目标应用现有的推送注册信息,并请求它重新向推送服务注册。目标应用可能会被打开或重启。 搜索 最近接收时间: 推送服务未找到 diff --git a/push/src/main/res/values/strings.xml b/push/src/main/res/values/strings.xml index fff0b537a..1c19e36d5 100644 --- a/push/src/main/res/values/strings.xml +++ b/push/src/main/res/values/strings.xml @@ -259,6 +259,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 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..54db4565f --- /dev/null +++ b/push/src/test/java/com/xiaomi/xmsf/push/notification/FocusNotificationSafetyTest.java @@ -0,0 +1,197 @@ +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.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 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/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)) + } +} From ed737b8aca829b2b39722ecfbb38b044e4df1a80 Mon Sep 17 00:00:00 2001 From: SherlockChiang Date: Wed, 19 Aug 2026 01:02:39 +0800 Subject: [PATCH 10/64] fix: parse activity click flags and diagnose config references --- .../common/utils/CustomConfiguration.java | 2 +- .../utils/utils/CustomConfigurationTest.java | 28 ++++++ .../ConfigurationReferenceDiagnostics.java | 90 +++++++++++++++++++ .../xmsf/push/utils/Configurations.java | 5 ++ .../xmsf/push/utils/ConfigurationsLoader.java | 59 +++++++++++- ...urationsLoaderReferenceDiagnosticTest.java | 62 +++++++++++++ 6 files changed, 242 insertions(+), 4 deletions(-) create mode 100644 push/src/main/java/com/xiaomi/xmsf/push/utils/ConfigurationReferenceDiagnostics.java create mode 100644 push/src/test/java/com/xiaomi/xmsf/push/utils/ConfigurationsLoaderReferenceDiagnosticTest.java 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 8920ca87e..751c7ef14 100644 --- a/common/src/main/java/top/trumeet/common/utils/CustomConfiguration.java +++ b/common/src/main/java/top/trumeet/common/utils/CustomConfiguration.java @@ -171,7 +171,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) { 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 93464f369..dfd22f39c 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 @@ -228,6 +228,34 @@ public void notificationShowWhenParsesValueInsteadOfPresence() { 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<>(); 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..05f814a34 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,11 @@ public void load(String json) throws JSONException { loader.load(json); } + /** Returns unresolved config references without inspecting notification payloads. */ + public List getUnresolvedReferences() { + return loader.getUnresolvedReferences(); + } + 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..bd82ea9b1 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; @@ -37,6 +38,10 @@ public class ConfigurationsLoader { private String version; private Map> packageConfigs = new HashMap<>(); + private final Map> referenceSites = + new HashMap<>(); + private List unresolvedReferences = + Collections.emptyList(); private Context mContext = null; private Uri mTreeUri = null; @@ -51,9 +56,20 @@ public Map> getConfigs() { return packageConfigs; } + /** + * 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 unresolvedReferences; + } + public boolean init(Context context, Uri treeUri) { mLastLoadTime = System.currentTimeMillis(); packageConfigs.clear(); + referenceSites.clear(); + unresolvedReferences = Collections.emptyList(); do { if (context == null || treeUri == null) { break; @@ -78,6 +94,7 @@ public boolean init(Context context, Uri treeUri) { } break; } + refreshUnresolvedReferences(); return true; } while (false); return false; @@ -135,7 +152,7 @@ private boolean parseDirectory(Context context, Uri treeUri, List(file, e)); @@ -145,10 +162,16 @@ private boolean parseDirectory(Context context, Uri treeUri, List", json); + } + + /** Loads an in-memory configuration while retaining a caller-provided diagnostic source name. */ + public void load(String sourceName, String json) throws JSONException { + parse(json, sourceName); + refreshUnresolvedReferences(); } - private void parse(String json) throws JSONException { + private void parse(String json, String sourceName) throws JSONException { JSONObject jsonObject = new JSONObject(json); version = jsonObject.getString("version"); JSONObject packageConfigsObj = jsonObject.getJSONObject("configs"); @@ -157,6 +180,36 @@ private void parse(String json) throws JSONException { String packageName = packageNames.next(); JSONArray configsObj = packageConfigsObj.getJSONArray(packageName); packageConfigs.put(packageName, parseConfigs(configsObj)); + 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 refreshUnresolvedReferences() { + List sites = new ArrayList<>(); + for (List ownerSites + : referenceSites.values()) { + sites.addAll(ownerSites); + } + unresolvedReferences = ConfigurationReferenceDiagnostics.resolve( + packageConfigs.keySet(), sites); + for (ConfigurationReferenceDiagnostics.UnresolvedReference diagnostic + : unresolvedReferences) { + logger.w("unresolved_configuration_reference source=[%s] owner=[%s] reference=[%s]", + diagnostic.getSourceName(), diagnostic.getOwnerKey(), diagnostic.getReference()); } } 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); + } +} From c43f2a328890c915756afb1488e1e09d9dcc4c9f Mon Sep 17 00:00:00 2001 From: SherlockChiang Date: Wed, 19 Aug 2026 16:48:29 +0800 Subject: [PATCH 11/64] fix: constrain notification click activities to target app --- .../service/MyMIPushNotificationHelper.java | 17 ++++++++++++- .../service/NotificationExecutorTest.java | 25 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) 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 d7459017e..d671c55ce 100644 --- a/push/src/main/java/com/xiaomi/push/service/MyMIPushNotificationHelper.java +++ b/push/src/main/java/com/xiaomi/push/service/MyMIPushNotificationHelper.java @@ -806,7 +806,9 @@ public static Intent getSdkIntent(Context context, XmPushActionContainer contain if (intent != null) { intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - if (context.getPackageManager().resolveActivity(intent, PackageManager.MATCH_DEFAULT_ONLY) != null) { + ResolveInfo resolvedActivity = context.getPackageManager() + .resolveActivity(intent, PackageManager.MATCH_DEFAULT_ONLY); + if (isResolvedActivityInTargetPackage(pkgName, resolvedActivity)) { //TODO fixit //we don't have RegSecret we cannot decode push action @@ -822,6 +824,19 @@ public static Intent getSdkIntent(Context context, XmPushActionContainer contain return null; } + /** + * 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); + } + /** * tmp black list * diff --git a/push/src/test/java/com/xiaomi/push/service/NotificationExecutorTest.java b/push/src/test/java/com/xiaomi/push/service/NotificationExecutorTest.java index 2728afc35..3aeae3d23 100644 --- a/push/src/test/java/com/xiaomi/push/service/NotificationExecutorTest.java +++ b/push/src/test/java/com/xiaomi/push/service/NotificationExecutorTest.java @@ -6,6 +6,9 @@ import com.elvishew.xlog.XLog; +import android.content.pm.ActivityInfo; +import android.content.pm.ResolveInfo; + import org.junit.Before; import org.junit.Test; @@ -63,4 +66,26 @@ public void styleActionsUseOfficialXiaomiKeys() { 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)); + } } From 42a6d01cf50d58fef21104a4ba62753209ed1e43 Mon Sep 17 00:00:00 2001 From: SherlockChiang Date: Wed, 19 Aug 2026 21:02:12 +0800 Subject: [PATCH 12/64] fix: prevent notification settings launch crashes --- .../NotificationPermissionController.java | 54 +++++++++++++++++-- .../utils/NotificationPermissionPolicy.java | 20 +++++++ .../NotificationPermissionPolicyTest.java | 12 +++++ 3 files changed, 82 insertions(+), 4 deletions(-) diff --git a/push/src/main/java/top/trumeet/mipushframework/utils/NotificationPermissionController.java b/push/src/main/java/top/trumeet/mipushframework/utils/NotificationPermissionController.java index 724372fc2..852c0183d 100644 --- a/push/src/main/java/top/trumeet/mipushframework/utils/NotificationPermissionController.java +++ b/push/src/main/java/top/trumeet/mipushframework/utils/NotificationPermissionController.java @@ -2,6 +2,7 @@ import android.Manifest; import android.app.Activity; +import android.content.ActivityNotFoundException; import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; @@ -45,11 +46,56 @@ public static void markRequested(@NonNull Context context) { } public static void openNotificationSettings(@NonNull Context context) { - Intent intent = new Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS) - .putExtra(Settings.EXTRA_APP_PACKAGE, context.getPackageName()) - .setData(Uri.parse("package:" + context.getPackageName())) + 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); - context.startActivity(intent); + 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 (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 diff --git a/push/src/main/java/top/trumeet/mipushframework/utils/NotificationPermissionPolicy.java b/push/src/main/java/top/trumeet/mipushframework/utils/NotificationPermissionPolicy.java index d34649820..fad4b4d40 100644 --- a/push/src/main/java/top/trumeet/mipushframework/utils/NotificationPermissionPolicy.java +++ b/push/src/main/java/top/trumeet/mipushframework/utils/NotificationPermissionPolicy.java @@ -20,6 +20,26 @@ public enum Status { 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, diff --git a/push/src/test/java/top/trumeet/mipushframework/utils/NotificationPermissionPolicyTest.java b/push/src/test/java/top/trumeet/mipushframework/utils/NotificationPermissionPolicyTest.java index dfc48161f..c9277578c 100644 --- a/push/src/test/java/top/trumeet/mipushframework/utils/NotificationPermissionPolicyTest.java +++ b/push/src/test/java/top/trumeet/mipushframework/utils/NotificationPermissionPolicyTest.java @@ -39,4 +39,16 @@ public void permanentlyDeniedRoutesToSettingsWithoutRepromptLoop() { 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)); + } } From fc44858c4dca74ce0c50646880b6646d7320ebba Mon Sep 17 00:00:00 2001 From: SherlockChiang Date: Wed, 19 Aug 2026 21:26:37 +0800 Subject: [PATCH 13/64] feat(ui): modernize application and event lists --- .../mipushframework/component/AppIcon.kt | 5 +- .../component/RefreshableLazyColumn.kt | 161 +++++++++++++++--- .../main/RegistrationStateStyle.kt | 22 ++- .../main/subpage/ApplicationListFilter.kt | 31 ++++ .../main/subpage/ApplicationListPage.kt | 140 +++++++++++---- .../main/subpage/EventListPage.kt | 78 ++++++--- push/src/main/res/values-zh/strings.xml | 6 + push/src/main/res/values/strings.xml | 6 + .../main/ApplicationListFilterTest.kt | 29 ++++ .../main/RegistrationStateStyleTest.kt | 38 +++++ 10 files changed, 430 insertions(+), 86 deletions(-) create mode 100644 push/src/main/java/top/trumeet/mipushframework/main/subpage/ApplicationListFilter.kt create mode 100644 push/src/test/java/top/trumeet/mipushframework/main/ApplicationListFilterTest.kt create mode 100644 push/src/test/java/top/trumeet/mipushframework/main/RegistrationStateStyleTest.kt 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..e8f4fa3b0 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 @@ -40,5 +41,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/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/main/RegistrationStateStyle.kt b/push/src/main/java/top/trumeet/mipushframework/main/RegistrationStateStyle.kt index 510e1b65d..e21ac25e1 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,12 @@ 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 "" + // 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 = if (shouldShowMissingServices(app.registeredType, app.existServices)) { + context.getString(R.string.mipush_services_not_found) + " - " + } else "" val color = colorOf(app) return when (app.registeredType) { RegisteredApplication.RegisteredType.Registered -> { @@ -33,20 +36,23 @@ object RegistrationStateStyle { } fun colorOf(app: RegisteredApplication): Color { - return if (!app.existServices) ErrorColor - else when (app.registeredType) { + return when (app.registeredType) { RegisteredApplication.RegisteredType.Registered -> { GreenColor } RegisteredApplication.RegisteredType.Unregistered -> { - YellowColor + if (!app.existServices) ErrorColor else YellowColor } // RegisteredApplication.RegisteredType.NotRegistered else -> { - Color.Unspecified + if (!app.existServices) ErrorColor else 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 = + !existServices && 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 ad1221cd0..f1165735f 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 @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.weight import androidx.compose.foundation.lazy.items import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf @@ -25,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 @@ -41,8 +43,11 @@ 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,30 +214,40 @@ 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 = MiuixTheme.textStyles.body1, @@ -179,7 +256,10 @@ private fun LastReceive(item: RegisteredApplication) { @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, 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 03b1c99b3..b8b1cef8b 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 @@ -48,6 +48,7 @@ import top.trumeet.mipushframework.component.MiuixDialog import top.trumeet.mipushframework.component.RefreshableLazyColumn import top.trumeet.mipushframework.component.TextView 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 @@ -196,28 +197,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 }) { @@ -230,23 +252,31 @@ 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 { + ConfigOptions(item) + ChannelInfo(item) + Spacer(Modifier.weight(1f)) + EventReceiveDate(item) + } + EventTitle(item) + EventContent(item) } - EventTitle(item) - EventContent(item) } } } diff --git a/push/src/main/res/values-zh/strings.xml b/push/src/main/res/values-zh/strings.xml index 8b3a02297..eac33a905 100644 --- a/push/src/main/res/values-zh/strings.xml +++ b/push/src/main/res/values-zh/strings.xml @@ -236,6 +236,12 @@ 重新注册 这会清除目标应用现有的推送注册信息,并请求它重新向推送服务注册。目标应用可能会被打开或重启。 搜索 + 刷新 + 回到顶部 + 全部 + 已注册 + 有问题 + 未注册 最近接收时间: 推送服务未找到 尝试强制注册所有应用 diff --git a/push/src/main/res/values/strings.xml b/push/src/main/res/values/strings.xml index 1c19e36d5..c3cbec7e9 100644 --- a/push/src/main/res/values/strings.xml +++ b/push/src/main/res/values/strings.xml @@ -301,6 +301,12 @@ 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 Try to force register all applications 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/RegistrationStateStyleTest.kt b/push/src/test/java/top/trumeet/mipushframework/main/RegistrationStateStyleTest.kt new file mode 100644 index 000000000..f13a9d4a4 --- /dev/null +++ b/push/src/test/java/top/trumeet/mipushframework/main/RegistrationStateStyleTest.kt @@ -0,0 +1,38 @@ +package top.trumeet.mipushframework.main + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import top.trumeet.mipush.provider.entities.RegisteredApplication.RegisteredType + +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, + ), + ) + } +} From 6f4f9d07008099f263dee5d3030b10fecc570d36 Mon Sep 17 00:00:00 2001 From: SherlockChiang Date: Wed, 19 Aug 2026 22:06:33 +0800 Subject: [PATCH 14/64] feat(settings): add configuration reference diagnostics --- .../main/subpage/SettingsPage.kt | 158 +++++++++++++++++- push/src/main/res/values-zh/strings.xml | 10 ++ push/src/main/res/values/strings.xml | 10 ++ 3 files changed, 176 insertions(+), 2 deletions(-) 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 2750beb9d..ffa1c6ea5 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 @@ -10,8 +10,14 @@ import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +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.foundation.layout.heightIn +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -23,11 +29,16 @@ 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 @@ -98,6 +109,7 @@ private fun AppearanceBlock( @Composable private fun ServiceConfigurationBlock() { val context = LocalContext.current + var diagnosticsRefreshToken by remember { mutableStateOf(0) } SettingsGroup(title = stringResource(R.string.settings_service_setting)) { SettingsItem( @@ -108,7 +120,13 @@ private fun ServiceConfigurationBlock() { } NotificationPermissionItem() - SetConfigurationsDirectory() + SetConfigurationsDirectory { + diagnosticsRefreshToken += 1 + } + ConfigurationDiagnosticsItem( + refreshToken = diagnosticsRefreshToken, + onRefresh = { diagnosticsRefreshToken += 1 }, + ) SetXMPPServer(context) } } @@ -216,7 +234,7 @@ private fun SetXMPPServer(context: Context) { } @Composable -private fun SetConfigurationsDirectory() { +private fun SetConfigurationsDirectory(onConfigurationChanged: () -> Unit = {}) { val context = LocalContext.current val launcher = rememberLauncherForActivityResult( contract = ActivityResultContracts.OpenDocumentTree() @@ -228,6 +246,7 @@ private fun SetConfigurationsDirectory() { ) SettingUtils.setConfigurationDirectory(context, uri) Global.ConfigCenter().loadConfigurations(context) + onConfigurationChanged() } } @@ -239,6 +258,141 @@ private fun SetConfigurationsDirectory() { } } +/** + * 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 private fun DebugBlock() { val context = LocalContext.current diff --git a/push/src/main/res/values-zh/strings.xml b/push/src/main/res/values-zh/strings.xml index eac33a905..7dcd74079 100644 --- a/push/src/main/res/values-zh/strings.xml +++ b/push/src/main/res/values-zh/strings.xml @@ -191,6 +191,16 @@ 进入高级配置界面 设置配置目录 + 配置引用检查 + 正在检查配置引用… + 尚未选择配置目录。 + 未发现缺失的配置引用。 + 发现 %1$d 个缺失的配置引用,相关规则可能不会生效。 + 无法读取或解析配置目录。 + 重新检查 + 来源 + 所属配置 + 引用 设置 XMPP 服务器 设置 发送/接收 消息的服务器 diff --git a/push/src/main/res/values/strings.xml b/push/src/main/res/values/strings.xml index c3cbec7e9..31261573d 100644 --- a/push/src/main/res/values/strings.xml +++ b/push/src/main/res/values/strings.xml @@ -220,6 +220,16 @@ Please grant the Run in the background or wake up permissions. 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 From 2dfcc148a893d57ba48d1526d01f76ab36525e38 Mon Sep 17 00:00:00 2001 From: SherlockChiang Date: Wed, 19 Aug 2026 22:09:27 +0800 Subject: [PATCH 15/64] feat(ui): card application details guidance --- .../main/ApplicationInfoPage.kt | 94 +++++++++++-------- .../main/subpage/ApplicationListPage.kt | 2 +- 2 files changed, 57 insertions(+), 39 deletions(-) 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 a220b905f..d7460ce48 100644 --- a/push/src/main/java/top/trumeet/mipushframework/main/ApplicationInfoPage.kt +++ b/push/src/main/java/top/trumeet/mipushframework/main/ApplicationInfoPage.kt @@ -323,16 +323,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) + } } } } @@ -340,12 +346,18 @@ 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() + } } } @@ -359,32 +371,38 @@ fun Tips( actionLabel: String? = null, onAction: (() -> Unit)? = null, ) { - 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 = MiuixTheme.textStyles.body2) - - MarkdownView( - description, - textSize = MiuixTheme.textStyles.footnote1.fontSize.value, + 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) ) - 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, + 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, + ) + } } } } 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 f1165735f..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,10 +7,10 @@ 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.layout.weight import androidx.compose.foundation.lazy.items import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf From d626c242894d7c5a86d64f6da36f56dfc031cff3 Mon Sep 17 00:00:00 2001 From: SherlockChiang Date: Wed, 19 Aug 2026 22:24:11 +0800 Subject: [PATCH 16/64] feat: publish configuration diagnostics snapshots --- .../ConfigurationDiagnosticsSnapshot.java | 59 ++++ .../xmsf/push/utils/Configurations.java | 5 + .../xmsf/push/utils/ConfigurationsLoader.java | 300 ++++++++++++++---- .../ConfigurationDiagnosticsSnapshotTest.java | 154 +++++++++ 4 files changed, 462 insertions(+), 56 deletions(-) create mode 100644 push/src/main/java/com/xiaomi/xmsf/push/utils/ConfigurationDiagnosticsSnapshot.java create mode 100644 push/src/test/java/com/xiaomi/xmsf/push/utils/ConfigurationDiagnosticsSnapshotTest.java 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/Configurations.java b/push/src/main/java/com/xiaomi/xmsf/push/utils/Configurations.java index 05f814a34..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 @@ -49,6 +49,11 @@ public List getUnresolved 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 bd82ea9b1..20a44a0f5 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 @@ -36,12 +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 final Map> referenceSites = - new HashMap<>(); - private List unresolvedReferences = - Collections.emptyList(); + private volatile PublishedConfigurationState publishedState = + PublishedConfigurationState.notConfigured(); private Context mContext = null; private Uri mTreeUri = null; @@ -53,7 +49,11 @@ public ConfigurationsLoader() { } public Map> getConfigs() { - return packageConfigs; + return publishedState.packageConfigs; + } + + public ConfigurationDiagnosticsSnapshot getDiagnosticsSnapshot() { + return publishedState.diagnosticsSnapshot; } /** @@ -62,23 +62,80 @@ public Map> getConfigs() { * never inspected or retained. */ public List getUnresolvedReferences() { - return unresolvedReferences; + return getDiagnosticsSnapshot().getUnresolvedReferences(); } - public boolean init(Context context, Uri treeUri) { + public synchronized boolean init(Context context, Uri treeUri) { mLastLoadTime = System.currentTimeMillis(); - packageConfigs.clear(); - referenceSites.clear(); - unresolvedReferences = Collections.emptyList(); - 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; + } + + 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; + } - if (!loadedFiles.isEmpty() && Global.ConfigCenter().isShowConfigurationListOnLoaded(context)) { + 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'); @@ -86,18 +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); } - refreshUnresolvedReferences(); - return true; - } while (false); - return false; + } } @NonNull @@ -135,30 +192,76 @@ 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 { - parse(json, file.getName()); + 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, 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 { @@ -166,21 +269,26 @@ public void load(String json) throws JSONException { } /** Loads an in-memory configuration while retaining a caller-provided diagnostic source name. */ - public void load(String sourceName, String json) throws JSONException { - parse(json, sourceName); - refreshUnresolvedReferences(); + 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) throws JSONException { + 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)); - referenceSites.put(packageName, findReferenceSites(sourceName, packageName, configsObj)); + candidate.packageConfigs.put(packageName, parseConfigs(configsObj)); + candidate.referenceSites.put( + packageName, findReferenceSites(sourceName, packageName, configsObj)); } } @@ -198,14 +306,20 @@ private static List findR return sites; } - private void refreshUnresolvedReferences() { + private void publish(MutableConfigurationState candidate) { List sites = new ArrayList<>(); for (List ownerSites - : referenceSites.values()) { + : candidate.referenceSites.values()) { sites.addAll(ownerSites); } - unresolvedReferences = ConfigurationReferenceDiagnostics.resolve( - packageConfigs.keySet(), sites); + 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]", @@ -258,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; } @@ -267,6 +381,80 @@ 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 = Collections.unmodifiableMap( + new HashMap<>(packageConfigs)); + this.referenceSites = Collections.unmodifiableMap( + new HashMap<>(referenceSites)); + this.diagnosticsSnapshot = diagnosticsSnapshot; + } + + 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/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..5d80dca66 --- /dev/null +++ b/push/src/test/java/com/xiaomi/xmsf/push/utils/ConfigurationDiagnosticsSnapshotTest.java @@ -0,0 +1,154 @@ +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()); + } + + @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); + } +} From be3de59439e9c37bb5373f0c183e6c13adf0ccb1 Mon Sep 17 00:00:00 2001 From: SherlockChiang Date: Wed, 19 Aug 2026 22:24:24 +0800 Subject: [PATCH 17/64] fix: catch settings resolver launch failures --- .../mipushframework/utils/NotificationPermissionController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/push/src/main/java/top/trumeet/mipushframework/utils/NotificationPermissionController.java b/push/src/main/java/top/trumeet/mipushframework/utils/NotificationPermissionController.java index 852c0183d..942a58168 100644 --- a/push/src/main/java/top/trumeet/mipushframework/utils/NotificationPermissionController.java +++ b/push/src/main/java/top/trumeet/mipushframework/utils/NotificationPermissionController.java @@ -64,7 +64,7 @@ public static void openNotificationSettings(@NonNull Context context) { boolean notificationSettingsResolvable; try { notificationSettingsResolvable = notificationSettings.resolveActivity(packageManager) != null; - } catch (SecurityException ignored) { + } catch (ActivityNotFoundException | SecurityException ignored) { notificationSettingsResolvable = false; } From a3dbf0c58a3615081bd5206179582e333e02ec2f Mon Sep 17 00:00:00 2001 From: SherlockChiang Date: Wed, 19 Aug 2026 22:25:43 +0800 Subject: [PATCH 18/64] chore: normalize application list sources --- .../mipushframework/main/subpage/ApplicationPageOperation.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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..0cb9d4fea 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 @@ -257,4 +257,4 @@ public static class MiPushApplications { public List res = new ArrayList(); public int totalPkg = 0; } -} \ No newline at end of file +} From 47314e966586165bef9c9dffdbee8102eff0ec34 Mon Sep 17 00:00:00 2001 From: SherlockChiang Date: Wed, 19 Aug 2026 22:28:33 +0800 Subject: [PATCH 19/64] fix: keep published configuration lists immutable --- .../xmsf/push/utils/ConfigurationsLoader.java | 30 ++++++++++++++++--- .../ConfigurationDiagnosticsSnapshotTest.java | 3 ++ 2 files changed, 29 insertions(+), 4 deletions(-) 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 20a44a0f5..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 @@ -423,13 +423,35 @@ private PublishedConfigurationState( referenceSites, ConfigurationDiagnosticsSnapshot diagnosticsSnapshot) { this.version = version; - this.packageConfigs = Collections.unmodifiableMap( - new HashMap<>(packageConfigs)); - this.referenceSites = Collections.unmodifiableMap( - new HashMap<>(referenceSites)); + 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()); } 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 index 5d80dca66..6faa178f0 100644 --- a/push/src/test/java/com/xiaomi/xmsf/push/utils/ConfigurationDiagnosticsSnapshotTest.java +++ b/push/src/test/java/com/xiaomi/xmsf/push/utils/ConfigurationDiagnosticsSnapshotTest.java @@ -90,6 +90,9 @@ public void successfulMemoryLoadPublishesReadyStateAndMissingReference() 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 From a9cfd34527281ab3f90c83b43166859ce1bfe4b6 Mon Sep 17 00:00:00 2001 From: SherlockChiang Date: Thu, 20 Aug 2026 01:59:47 +0800 Subject: [PATCH 20/64] test: provide JSON implementation for local parser tests --- push/build.gradle | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/push/build.gradle b/push/build.gradle index e0716e70a..cde0c2e9a 100644 --- a/push/build.gradle +++ b/push/build.gradle @@ -212,6 +212,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") From 44f14206dee7e109860d9d4df3b21598bb3d11c1 Mon Sep 17 00:00:00 2001 From: SherlockChiang Date: Thu, 20 Aug 2026 02:05:45 +0800 Subject: [PATCH 21/64] ui: distinguish unavailable MiPush service probes --- .../entities/RegisteredApplication.java | 37 +++++++++++++++++++ .../main/RegistrationStateStyle.kt | 37 ++++++++++++++++--- .../subpage/ApplicationPageOperation.java | 32 ++++++++++++++-- push/src/main/res/values-zh/strings.xml | 1 + push/src/main/res/values/strings.xml | 1 + .../main/RegistrationStateStyleTest.kt | 17 +++++++++ 6 files changed, 116 insertions(+), 9 deletions(-) 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/main/RegistrationStateStyle.kt b/push/src/main/java/top/trumeet/mipushframework/main/RegistrationStateStyle.kt index e21ac25e1..2d5fd4ee1 100644 --- a/push/src/main/java/top/trumeet/mipushframework/main/RegistrationStateStyle.kt +++ b/push/src/main/java/top/trumeet/mipushframework/main/RegistrationStateStyle.kt @@ -15,9 +15,14 @@ object RegistrationStateStyle { // 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 = if (shouldShowMissingServices(app.registeredType, app.existServices)) { - context.getString(R.string.mipush_services_not_found) + " - " - } else "" + val prefix = when { + app.registeredType == RegisteredApplication.RegisteredType.Registered -> "" + shouldShowMissingServices(app.registeredType, app.serviceProbeState) -> + context.getString(R.string.mipush_services_not_found) + " - " + app.serviceProbeState == RegisteredApplication.ServiceProbeState.UNKNOWN -> + context.getString(R.string.mipush_services_unknown) + " - " + else -> "" + } val color = colorOf(app) return when (app.registeredType) { RegisteredApplication.RegisteredType.Registered -> { @@ -42,17 +47,37 @@ object RegistrationStateStyle { } RegisteredApplication.RegisteredType.Unregistered -> { - if (!app.existServices) ErrorColor else YellowColor + when (app.serviceProbeState) { + RegisteredApplication.ServiceProbeState.MISSING -> ErrorColor + RegisteredApplication.ServiceProbeState.UNKNOWN -> YellowColor + RegisteredApplication.ServiceProbeState.PRESENT -> YellowColor + } } // RegisteredApplication.RegisteredType.NotRegistered else -> { - if (!app.existServices) ErrorColor else Color.Unspecified + when (app.serviceProbeState) { + RegisteredApplication.ServiceProbeState.MISSING -> ErrorColor + RegisteredApplication.ServiceProbeState.UNKNOWN -> YellowColor + RegisteredApplication.ServiceProbeState.PRESENT -> Color.Unspecified + } } } } /** Missing-service diagnostics apply only to rows that are not already registered. */ fun shouldShowMissingServices(registeredType: Int, existServices: Boolean): Boolean = - !existServices && registeredType != RegisteredApplication.RegisteredType.Registered + 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/ApplicationPageOperation.java b/push/src/main/java/top/trumeet/mipushframework/main/subpage/ApplicationPageOperation.java index 0cb9d4fea..be1241cdc 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 @@ -104,7 +104,10 @@ public static void addApplicationNameIfMissing(List res) } else { application = registerApplication(currentAppPkgName); } - application.existServices = hasMiPushServices(checker, info); + RegisteredApplication.ServiceProbeState probeState = probeMiPushServices(checker, info); + application.serviceProbeState = probeState; + // Keep the legacy boolean populated for Java/Kotlin callers and older UI code. + application.existServices = probeState == RegisteredApplication.ServiceProbeState.PRESENT; return application; } @@ -120,11 +123,34 @@ 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 checker.checkServices(info) + ? RegisteredApplication.ServiceProbeState.PRESENT + : RegisteredApplication.ServiceProbeState.MISSING; + } 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; + } + } + + /** 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) { diff --git a/push/src/main/res/values-zh/strings.xml b/push/src/main/res/values-zh/strings.xml index 7dcd74079..07800a9f0 100644 --- a/push/src/main/res/values-zh/strings.xml +++ b/push/src/main/res/values-zh/strings.xml @@ -254,6 +254,7 @@ 未注册 最近接收时间: 推送服务未找到 + 无法确认推送服务状态 尝试强制注册所有应用 推送服务需要加入“电池优化”白名单才能正常运行。 聚合同一会话的所有通知 diff --git a/push/src/main/res/values/strings.xml b/push/src/main/res/values/strings.xml index 31261573d..be6b0db25 100644 --- a/push/src/main/res/values/strings.xml +++ b/push/src/main/res/values/strings.xml @@ -319,6 +319,7 @@ This could be the application doing a reverse registration, or the registration Not registered last receive: Services Not Found + Unable to verify push service status Try to force register all applications diff --git a/push/src/test/java/top/trumeet/mipushframework/main/RegistrationStateStyleTest.kt b/push/src/test/java/top/trumeet/mipushframework/main/RegistrationStateStyleTest.kt index f13a9d4a4..c41c6b449 100644 --- a/push/src/test/java/top/trumeet/mipushframework/main/RegistrationStateStyleTest.kt +++ b/push/src/test/java/top/trumeet/mipushframework/main/RegistrationStateStyleTest.kt @@ -4,6 +4,7 @@ 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 @@ -35,4 +36,20 @@ class RegistrationStateStyleTest { ), ) } + + @Test + fun unknownServiceProbeDoesNotReportMissingServices() { + assertFalse( + RegistrationStateStyle.shouldShowMissingServices( + RegisteredType.NotRegistered, + ServiceProbeState.UNKNOWN, + ), + ) + assertFalse( + RegistrationStateStyle.shouldShowMissingServices( + RegisteredType.Unregistered, + ServiceProbeState.UNKNOWN, + ), + ) + } } From 32be647bbe174a7f9f12b958eb9d85fd824d3fec Mon Sep 17 00:00:00 2001 From: SherlockChiang Date: Thu, 20 Aug 2026 02:12:03 +0800 Subject: [PATCH 22/64] fix: preserve unknown manifest probe failures --- .../subpage/ApplicationPageOperation.java | 23 +++++++++++--- .../utils/MiPushManifestChecker.java | 30 +++++++++++++++---- .../ApplicationPageServiceProbeMappingTest.kt | 29 ++++++++++++++++++ 3 files changed, 72 insertions(+), 10 deletions(-) create mode 100644 push/src/test/java/top/trumeet/mipushframework/main/ApplicationPageServiceProbeMappingTest.kt 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 be1241cdc..f5f1f9d63 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 @@ -105,7 +105,7 @@ public static void addApplicationNameIfMissing(List res) application = registerApplication(currentAppPkgName); } RegisteredApplication.ServiceProbeState probeState = probeMiPushServices(checker, info); - application.serviceProbeState = probeState; + application.setServiceProbeState(probeState); // Keep the legacy boolean populated for Java/Kotlin callers and older UI code. application.existServices = probeState == RegisteredApplication.ServiceProbeState.PRESENT; return application; @@ -138,9 +138,7 @@ public static RegisteredApplication.ServiceProbeState probeMiPushServices( return RegisteredApplication.ServiceProbeState.UNKNOWN; } try { - return checker.checkServices(info) - ? RegisteredApplication.ServiceProbeState.PRESENT - : RegisteredApplication.ServiceProbeState.MISSING; + 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. @@ -148,6 +146,23 @@ public static RegisteredApplication.ServiceProbeState probeMiPushServices( } } + /** 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 probeMiPushServices(checker, info) == RegisteredApplication.ServiceProbeState.PRESENT; 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/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..d1f02e5a2 --- /dev/null +++ b/push/src/test/java/top/trumeet/mipushframework/main/ApplicationPageServiceProbeMappingTest.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.ServiceProbeState +import top.trumeet.mipushframework.main.subpage.ApplicationPageOperation +import top.trumeet.mipushframework.utils.MiPushManifestChecker.ServiceCheckResult + +class ApplicationPageServiceProbeMappingTest { + @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), + ) + } +} From e5e7eb2297e93aa7ccb1c2431dd543e0cc6985c4 Mon Sep 17 00:00:00 2001 From: SherlockChiang Date: Thu, 20 Aug 2026 02:18:35 +0800 Subject: [PATCH 23/64] fix: honor legacy service probe flags --- .../main/RegistrationStateStyle.kt | 13 +++++++++---- .../main/RegistrationStateStyleTest.kt | 16 ++++++++++++++++ 2 files changed, 25 insertions(+), 4 deletions(-) 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 2d5fd4ee1..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,14 +12,18 @@ object RegistrationStateStyle { val YellowColor = Color(0xffff9800) fun contentOf(app: RegisteredApplication, context: Context): Pair { + // 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, app.serviceProbeState) -> + shouldShowMissingServices(app.registeredType, probeState) -> context.getString(R.string.mipush_services_not_found) + " - " - app.serviceProbeState == RegisteredApplication.ServiceProbeState.UNKNOWN -> + probeState == RegisteredApplication.ServiceProbeState.UNKNOWN -> context.getString(R.string.mipush_services_unknown) + " - " else -> "" } @@ -41,13 +45,14 @@ object RegistrationStateStyle { } fun colorOf(app: RegisteredApplication): Color { + val probeState = app.getServiceProbeState() return when (app.registeredType) { RegisteredApplication.RegisteredType.Registered -> { GreenColor } RegisteredApplication.RegisteredType.Unregistered -> { - when (app.serviceProbeState) { + when (probeState) { RegisteredApplication.ServiceProbeState.MISSING -> ErrorColor RegisteredApplication.ServiceProbeState.UNKNOWN -> YellowColor RegisteredApplication.ServiceProbeState.PRESENT -> YellowColor @@ -56,7 +61,7 @@ object RegistrationStateStyle { // RegisteredApplication.RegisteredType.NotRegistered else -> { - when (app.serviceProbeState) { + when (probeState) { RegisteredApplication.ServiceProbeState.MISSING -> ErrorColor RegisteredApplication.ServiceProbeState.UNKNOWN -> YellowColor RegisteredApplication.ServiceProbeState.PRESENT -> Color.Unspecified diff --git a/push/src/test/java/top/trumeet/mipushframework/main/RegistrationStateStyleTest.kt b/push/src/test/java/top/trumeet/mipushframework/main/RegistrationStateStyleTest.kt index c41c6b449..4e7a9dddb 100644 --- a/push/src/test/java/top/trumeet/mipushframework/main/RegistrationStateStyleTest.kt +++ b/push/src/test/java/top/trumeet/mipushframework/main/RegistrationStateStyleTest.kt @@ -1,5 +1,6 @@ package top.trumeet.mipushframework.main +import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test @@ -52,4 +53,19 @@ class RegistrationStateStyleTest { ), ) } + + @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(), + ), + ) + } } From 7bebe16f792850522e2d4998dca625b23de64b51 Mon Sep 17 00:00:00 2001 From: SherlockChiang Date: Thu, 20 Aug 2026 02:29:49 +0800 Subject: [PATCH 24/64] Isolate notification dispatch phases --- .../service/MyMIPushNotificationHelper.java | 31 ++-- .../service/NotificationDispatchPipeline.java | 100 +++++++++++++ .../NotificationDispatchPipelineTest.java | 136 ++++++++++++++++++ 3 files changed, 252 insertions(+), 15 deletions(-) create mode 100644 push/src/main/java/com/xiaomi/push/service/NotificationDispatchPipeline.java create mode 100644 push/src/test/java/com/xiaomi/push/service/NotificationDispatchPipelineTest.java 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 d671c55ce..4da6c5b66 100644 --- a/push/src/main/java/com/xiaomi/push/service/MyMIPushNotificationHelper.java +++ b/push/src/main/java/com/xiaomi/push/service/MyMIPushNotificationHelper.java @@ -53,7 +53,6 @@ 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.utils.ConfigCenter; import java.net.MalformedURLException; @@ -183,27 +182,29 @@ public static void notifyPushMessage(Context context, byte[] decryptedContent) { 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(appContext, packageName); - } - if (!operations.contains(PackageConfig.OPERATION_IGNORE)) { - executorService.execute(() -> { + NotificationDispatchPipeline.DispatchPlan plan = + NotificationDispatchPipeline.planFromOperations(operations); + NotificationDispatchPipeline.dispatch( + plan, + () -> wakeScreen(appContext, packageName), + () -> executorService.execute(() -> { try { doNotifyPushMessage(appContext, container, decryptedContent); } catch (Exception e) { logger.e(e.getLocalizedMessage(), e); } - }); - } - if (operations.contains(PackageConfig.OPERATION_OPEN)) { - MyPushMessageHandler.startService(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)); } private static void loadConfigurationsOnce(Context context) { 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/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; + } +} From e4edb59d86e8261c557bc9050b5fab0f88d9c98f Mon Sep 17 00:00:00 2001 From: SherlockChiang Date: Thu, 20 Aug 2026 07:56:58 +0800 Subject: [PATCH 25/64] test: initialize logging for service probe mapping --- .../main/ApplicationPageServiceProbeMappingTest.kt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/push/src/test/java/top/trumeet/mipushframework/main/ApplicationPageServiceProbeMappingTest.kt b/push/src/test/java/top/trumeet/mipushframework/main/ApplicationPageServiceProbeMappingTest.kt index d1f02e5a2..6293f5ecc 100644 --- a/push/src/test/java/top/trumeet/mipushframework/main/ApplicationPageServiceProbeMappingTest.kt +++ b/push/src/test/java/top/trumeet/mipushframework/main/ApplicationPageServiceProbeMappingTest.kt @@ -1,12 +1,19 @@ 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( From b707f060c89c3e1618da846dab9ed5323e4e87b9 Mon Sep 17 00:00:00 2001 From: SherlockChiang Date: Thu, 20 Aug 2026 08:33:35 +0800 Subject: [PATCH 26/64] perf: throttle duplicate screen wakes per package --- .../service/MyMIPushNotificationHelper.java | 13 +++ .../push/service/WakeScreenThrottle.java | 90 +++++++++++++++++ .../push/service/WakeScreenThrottleTest.java | 98 +++++++++++++++++++ 3 files changed, 201 insertions(+) create mode 100644 push/src/main/java/com/xiaomi/push/service/WakeScreenThrottle.java create mode 100644 push/src/test/java/com/xiaomi/push/service/WakeScreenThrottleTest.java 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 4da6c5b66..751bac1f3 100644 --- a/push/src/main/java/com/xiaomi/push/service/MyMIPushNotificationHelper.java +++ b/push/src/main/java/com/xiaomi/push/service/MyMIPushNotificationHelper.java @@ -25,6 +25,7 @@ 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; @@ -138,6 +139,15 @@ public class MyMIPushNotificationHelper { new java.util.concurrent.atomic.AtomicInteger(1); private static final java.util.concurrent.ThreadPoolExecutor executorService = createNotificationExecutor(); + /** + * 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; } @@ -233,6 +243,9 @@ private static void wakeScreen(Context context, String sourcePackage) { 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 | 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/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()); + } +} From 43bee1b19b926618279c9db53cc5d12f974d245e Mon Sep 17 00:00:00 2001 From: SherlockChiang Date: Thu, 20 Aug 2026 08:59:22 +0800 Subject: [PATCH 27/64] ui: add swipe navigation between main pages --- .../trumeet/mipushframework/main/MainPage.kt | 47 ++++++++++++++++++- .../main/MainPageSwipeNavigation.kt | 31 ++++++++++++ .../main/MainPageSwipeNavigationTest.kt | 36 ++++++++++++++ 3 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 push/src/main/java/top/trumeet/mipushframework/main/MainPageSwipeNavigation.kt create mode 100644 push/src/test/java/top/trumeet/mipushframework/main/MainPageSwipeNavigationTest.kt 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 2171a6990..0ed84fd7a 100644 --- a/push/src/main/java/top/trumeet/mipushframework/main/MainPage.kt +++ b/push/src/main/java/top/trumeet/mipushframework/main/MainPage.kt @@ -5,6 +5,7 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.consumeWindowInsets @@ -16,6 +17,7 @@ import androidx.compose.runtime.Composable 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 @@ -25,6 +27,8 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.toArgb 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 @@ -255,6 +259,16 @@ private fun Main( 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() } MiuixPageScaffold( modifier = Modifier.fillMaxSize(), @@ -289,7 +303,38 @@ private fun Main( modifier = Modifier .fillMaxSize() .padding(paddingValues) - .consumeWindowInsets(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 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/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)) + } +} From bd4ea7f768a1f469d2a9943c3035c0f7ced8bdfc Mon Sep 17 00:00:00 2001 From: SherlockChiang Date: Thu, 20 Aug 2026 09:12:58 +0800 Subject: [PATCH 28/64] feat(ui): add safe manual notification replay --- .../main/subpage/EventListPage.kt | 46 ++++++++--- .../main/subpage/EventListPageUtils.java | 82 ++++++++++++++++++- push/src/main/res/values-zh/strings.xml | 7 ++ push/src/main/res/values/strings.xml | 7 ++ .../main/subpage/EventListPageUtilsTest.java | 38 ++++++++- 5 files changed, 168 insertions(+), 12 deletions(-) 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 b8b1cef8b..8419de1d7 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 @@ -131,6 +131,15 @@ private fun EventDetailsDialog( val targetHeight = screenHeight * 0.9f 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, @@ -142,15 +151,31 @@ private fun EventDetailsDialog( Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, + horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { + MiuixActionButton( + modifier = Modifier.weight(1f), + enabled = canReplay, + onClick = { + replayStatus = EventListPageUtils.replayEvent(clickedEvent.event) + }, + ) { Text(stringResource(R.string.action_replay_notification)) } MiuixActionButton(onClick = { EventListPageUtils.startManagePermissions( context, clickedEvent.packageName ) - }) { Text(stringResource(R.string.action_app_info)) } + }) { + Text(stringResource(R.string.action_app_info)) + } + } + replayStatus?.let { status -> + Text( + text = stringResource(replayStatusMessage(status)), + style = MiuixTheme.textStyles.footnote1, + color = MiuixTheme.colorScheme.onSurfaceVariantSummary, + ) } TextView(json) Row( @@ -168,19 +193,20 @@ private fun EventDetailsDialog( MiuixActionButton(onClick = { EventListPageUtils.copyToClipboard(context, json) }) { Text(stringResource(android.R.string.copy)) } - - MiuixActionButton(onClick = { - EventListPageUtils.mockMessage( - RegSecUtils.getContainerWithRegSec( - clickedEvent.event - ) - ) - }) { Text(stringResource(R.string.action_notify)) } } } } } +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() @Composable 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..4bd135243 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 @@ -22,6 +22,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 +39,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 +100,64 @@ 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; + } + MockMIPushMessage.mockProcessMIPushMessage(service, container.deepCopy()); + return ReplayStatus.DISPATCHED; + } 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(); @@ -195,4 +275,4 @@ private String getStatusDescriptionByEvent(@NonNull Event item) { return ""; } -} \ No newline at end of file +} diff --git a/push/src/main/res/values-zh/strings.xml b/push/src/main/res/values-zh/strings.xml index 07800a9f0..513a0b755 100644 --- a/push/src/main/res/values-zh/strings.xml +++ b/push/src/main/res/values-zh/strings.xml @@ -163,6 +163,13 @@ 修改权限(保存 ----> 应用信息 + 手动弹出通知 + 可以再次弹出这条通知。 + 已请求再次弹出通知。 + 推送服务未运行,请启动服务后重试。 + 这条记录没有有效的通知内容。 + 只有通知记录可以再次弹出。 + 通知再次弹出失败。 权限 diff --git a/push/src/main/res/values/strings.xml b/push/src/main/res/values/strings.xml index be6b0db25..c072f8e01 100644 --- a/push/src/main/res/values/strings.xml +++ b/push/src/main/res/values/strings.xml @@ -181,7 +181,14 @@ 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. Permissions 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)); + } +} From fd6eac402c7aa54c39c8c8bc3427750c879a6cac Mon Sep 17 00:00:00 2001 From: SherlockChiang Date: Thu, 20 Aug 2026 09:29:51 +0800 Subject: [PATCH 29/64] fix: restore HyperOS conversation mini-window hint --- .../service/MyMIPushNotificationHelper.java | 30 ++++++++++++++++--- .../service/NotificationExecutorTest.java | 14 +++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) 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 751bac1f3..0a205742a 100644 --- a/push/src/main/java/com/xiaomi/push/service/MyMIPushNotificationHelper.java +++ b/push/src/main/java/com/xiaomi/push/service/MyMIPushNotificationHelper.java @@ -336,6 +336,14 @@ 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(custom.notificationShowWhen(true)); @@ -347,7 +355,8 @@ private static NotificationInfo getNotificationFor(Context context, XmPushAction intentExtra.putExtra(Constants.INTENT_NOTIFICATION_GROUP, notificationBuilder.build().getGroup()); PendingIntent localPendingIntent = getClickedPendingIntent( - context, container, decryptedContent, notificationId, intentExtra.getExtras()); + context, container, decryptedContent, notificationId, intentExtra.getExtras(), + useMessagingStyle); if (localPendingIntent != null) { notificationBuilder.setContentIntent(localPendingIntent); @@ -692,7 +701,7 @@ private static PendingIntent openActivityPendingIntent(Context paramContext, XmP private static PendingIntent 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; @@ -725,9 +734,10 @@ 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) { + boolean useActivity = shouldUseActivityClick( + configuration.useClickedActivity(false), messagingStyle, activityIntent); + if (!useActivity) { return PendingIntent.getService(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); } @@ -738,6 +748,18 @@ private static PendingIntent getClickedPendingIntent( PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); } + /** + * 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, so + * they may use that path by default. Other notification types retain the + * historical service PendingIntent unless configuration explicitly opts in. + */ + static boolean shouldUseActivityClick( + boolean explicitlyRequested, boolean messagingStyle, @Nullable Intent activityIntent) { + return activityIntent != null && (explicitlyRequested || messagingStyle); + } + /** * @see PushMessageProcessor#getNotificationMessageIntent */ diff --git a/push/src/test/java/com/xiaomi/push/service/NotificationExecutorTest.java b/push/src/test/java/com/xiaomi/push/service/NotificationExecutorTest.java index 3aeae3d23..f7cf2e505 100644 --- a/push/src/test/java/com/xiaomi/push/service/NotificationExecutorTest.java +++ b/push/src/test/java/com/xiaomi/push/service/NotificationExecutorTest.java @@ -7,6 +7,7 @@ import com.elvishew.xlog.XLog; import android.content.pm.ActivityInfo; +import android.content.Intent; import android.content.pm.ResolveInfo; import org.junit.Before; @@ -88,4 +89,17 @@ public void resolvedActivityMustBelongToTargetPackage() { assertTrue(!MyMIPushNotificationHelper.isResolvedActivityInTargetPackage( "", resolved)); } + + @Test + public void messagingNotificationsMayUseValidatedActivityClickByDefault() { + Intent activity = new Intent(); + assertTrue(MyMIPushNotificationHelper.shouldUseActivityClick( + false, true, activity)); + assertTrue(MyMIPushNotificationHelper.shouldUseActivityClick( + true, false, activity)); + assertTrue(!MyMIPushNotificationHelper.shouldUseActivityClick( + false, false, activity)); + assertTrue(!MyMIPushNotificationHelper.shouldUseActivityClick( + true, true, null)); + } } From c4d0d3aaa3bd73c29f9efe2d46b526fed3eaab76 Mon Sep 17 00:00:00 2001 From: SherlockChiang Date: Thu, 20 Aug 2026 10:56:18 +0800 Subject: [PATCH 30/64] fix: preserve explicit notification click setting --- .../service/MyMIPushNotificationHelper.java | 21 +++++++++++++++--- .../service/NotificationExecutorTest.java | 22 ++++++++++++++----- 2 files changed, 35 insertions(+), 8 deletions(-) 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 0a205742a..f47862c19 100644 --- a/push/src/main/java/com/xiaomi/push/service/MyMIPushNotificationHelper.java +++ b/push/src/main/java/com/xiaomi/push/service/MyMIPushNotificationHelper.java @@ -735,8 +735,14 @@ private static PendingIntent getClickedPendingIntent( CustomConfiguration configuration = XMPushUtils.getConfiguration(metaInfo); Intent activityIntent = getSdkIntent(context, container); + // Keep the setting tri-state: an absent key means "use the + // MessagingStyle default", while an explicitly supplied false must + // continue to request the historical service PendingIntent. + Boolean explicitSetting = configuration.keys().contains("use_clicked_activity") + ? configuration.useClickedActivity(false) + : null; boolean useActivity = shouldUseActivityClick( - configuration.useClickedActivity(false), messagingStyle, activityIntent); + explicitSetting, messagingStyle, activityIntent); if (!useActivity) { return PendingIntent.getService(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); @@ -756,8 +762,17 @@ private static PendingIntent getClickedPendingIntent( * historical service PendingIntent unless configuration explicitly opts in. */ static boolean shouldUseActivityClick( - boolean explicitlyRequested, boolean messagingStyle, @Nullable Intent activityIntent) { - return activityIntent != null && (explicitlyRequested || messagingStyle); + @Nullable Boolean explicitSetting, boolean messagingStyle, + @Nullable Intent activityIntent) { + // A missing/invalid target can never be upgraded to an Activity + // PendingIntent. The caller supplies only intents already validated by + // getSdkIntent, while this guard keeps the fallback safe for all paths. + if (activityIntent == null) { + return false; + } + // Explicit configuration always wins over the MessagingStyle default, + // including an explicit false. + return explicitSetting != null ? explicitSetting : messagingStyle; } /** diff --git a/push/src/test/java/com/xiaomi/push/service/NotificationExecutorTest.java b/push/src/test/java/com/xiaomi/push/service/NotificationExecutorTest.java index f7cf2e505..7a19cf315 100644 --- a/push/src/test/java/com/xiaomi/push/service/NotificationExecutorTest.java +++ b/push/src/test/java/com/xiaomi/push/service/NotificationExecutorTest.java @@ -91,15 +91,27 @@ public void resolvedActivityMustBelongToTargetPackage() { } @Test - public void messagingNotificationsMayUseValidatedActivityClickByDefault() { + public void clickedActivitySettingUsesThreeStateContract() { Intent activity = new Intent(); + + // Explicit values take precedence over the style-derived default. assertTrue(MyMIPushNotificationHelper.shouldUseActivityClick( - false, true, activity)); + Boolean.TRUE, false, activity)); + assertTrue(!MyMIPushNotificationHelper.shouldUseActivityClick( + Boolean.FALSE, true, activity)); + + // An absent setting opts MessagingStyle into the Activity path, while + // non-MessagingStyle notifications retain the service path. assertTrue(MyMIPushNotificationHelper.shouldUseActivityClick( - true, false, activity)); + null, true, activity)); + assertTrue(!MyMIPushNotificationHelper.shouldUseActivityClick( + null, false, activity)); + + // No resolved target Activity must always use the safe service path, + // even when the setting or style asks for an Activity. assertTrue(!MyMIPushNotificationHelper.shouldUseActivityClick( - false, false, activity)); + Boolean.TRUE, true, null)); assertTrue(!MyMIPushNotificationHelper.shouldUseActivityClick( - true, true, null)); + null, true, null)); } } From 19c4c052d889f0cc3751b8d68beb0eb029a656d0 Mon Sep 17 00:00:00 2001 From: SherlockChiang Date: Thu, 20 Aug 2026 12:52:19 +0800 Subject: [PATCH 31/64] fix: use Miuix action buttons and open app permissions --- .../main/ApplicationInfoPage.kt | 10 +++- .../main/subpage/EventListPage.kt | 50 ++++++++++++------- .../main/subpage/EventListPageUtils.java | 5 +- 3 files changed, 44 insertions(+), 21 deletions(-) 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 d7460ce48..78a5fbd9f 100644 --- a/push/src/main/java/top/trumeet/mipushframework/main/ApplicationInfoPage.kt +++ b/push/src/main/java/top/trumeet/mipushframework/main/ApplicationInfoPage.kt @@ -93,7 +93,15 @@ 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 = MiuixTheme.colorScheme.surfaceContainer.toArgb() 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 8419de1d7..51a1fca1d 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 @@ -43,10 +43,10 @@ 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.MiuixActionButton 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 @@ -154,21 +154,28 @@ private fun EventDetailsDialog( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { - MiuixActionButton( + Button( modifier = Modifier.weight(1f), + text = stringResource(R.string.action_replay_notification), enabled = canReplay, onClick = { replayStatus = EventListPageUtils.replayEvent(clickedEvent.event) }, - ) { Text(stringResource(R.string.action_replay_notification)) } - MiuixActionButton(onClick = { - EventListPageUtils.startManagePermissions( - context, - clickedEvent.packageName - ) - }) { - Text(stringResource(R.string.action_app_info)) - } + ) + 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. + EventListPageUtils.startManagePermissions( + context, + clickedEvent.packageName, + true, + ) + }, + ) } replayStatus?.let { status -> Text( @@ -180,19 +187,24 @@ private fun EventDetailsDialog( TextView(json) Row( Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - MiuixActionButton(onClick = { - 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)) } + }, + ) - MiuixActionButton(onClick = { - EventListPageUtils.copyToClipboard(context, json) - }) { Text(stringResource(android.R.string.copy)) } + Button( + modifier = Modifier.weight(1f), + text = stringResource(android.R.string.copy), + onClick = { EventListPageUtils.copyToClipboard(context, json) }, + ) } } } 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 4bd135243..17db72651 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; @@ -213,7 +214,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) { From cec00ad30304996404b474423f015d4cc6a1ff39 Mon Sep 17 00:00:00 2001 From: SherlockChiang Date: Thu, 20 Aug 2026 13:13:05 +0800 Subject: [PATCH 32/64] fix: dismiss event dialog before opening app permissions --- .../top/trumeet/mipushframework/main/subpage/EventListPage.kt | 2 ++ 1 file changed, 2 insertions(+) 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 51a1fca1d..3881cd593 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 @@ -169,6 +169,8 @@ private fun EventDetailsDialog( // 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, From aa71fc5b1a690d311f2791eaaa444b57e4f4092f Mon Sep 17 00:00:00 2001 From: SherlockChiang Date: Fri, 21 Aug 2026 19:55:29 +0800 Subject: [PATCH 33/64] perf: bound notification queue and trim icon caches --- .../common/cache/ApplicationNameCache.java | 5 +++ .../top/trumeet/common/cache/IconCache.java | 31 +++++++++++++++++++ .../service/MyMIPushNotificationHelper.java | 8 ++++- .../service/NotificationExecutorTest.java | 4 ++- 4 files changed, 46 insertions(+), 2 deletions(-) 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..6fa1f68f8 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,10 @@ 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(); + } + } 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..ba9b1fc5a 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,37 @@ 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) { + if (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); + } + } + + 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/push/src/main/java/com/xiaomi/push/service/MyMIPushNotificationHelper.java b/push/src/main/java/com/xiaomi/push/service/MyMIPushNotificationHelper.java index f47862c19..bf6f23218 100644 --- a/push/src/main/java/com/xiaomi/push/service/MyMIPushNotificationHelper.java +++ b/push/src/main/java/com/xiaomi/push/service/MyMIPushNotificationHelper.java @@ -137,6 +137,12 @@ public class MyMIPushNotificationHelper { 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(); /** @@ -158,7 +164,7 @@ private static java.util.concurrent.ThreadPoolExecutor createNotificationExecuto 3, 30L, java.util.concurrent.TimeUnit.SECONDS, - new java.util.concurrent.ArrayBlockingQueue<>(32), + new java.util.concurrent.ArrayBlockingQueue<>(NOTIFICATION_QUEUE_CAPACITY), r -> { Thread t = new Thread(() -> { try { diff --git a/push/src/test/java/com/xiaomi/push/service/NotificationExecutorTest.java b/push/src/test/java/com/xiaomi/push/service/NotificationExecutorTest.java index 7a19cf315..9c6b4c906 100644 --- a/push/src/test/java/com/xiaomi/push/service/NotificationExecutorTest.java +++ b/push/src/test/java/com/xiaomi/push/service/NotificationExecutorTest.java @@ -35,7 +35,9 @@ public void testNotificationExecutorConfiguration() { 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 be 32", 32, executor.getQueue().remainingCapacity() + executor.getQueue().size()); + 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); From ba83d71fcf369237da8d2d4dc5828517f255ab9a Mon Sep 17 00:00:00 2001 From: SherlockChiang Date: Fri, 21 Aug 2026 21:28:02 +0800 Subject: [PATCH 34/64] fix: restore focus payload and observable test notifications --- .../java/com/xiaomi/xmsf/SettingUtils.java | 12 ++ .../NotificationChannelManager.java | 26 ++++ .../notification/NotificationController.java | 121 +++++++++++++++--- .../notification/NotificationManagerEx.kt | 30 ++++- push/src/main/res/values-zh/strings.xml | 2 +- push/src/main/res/values/strings.xml | 2 +- 6 files changed, 172 insertions(+), 21 deletions(-) diff --git a/push/src/main/java/com/xiaomi/xmsf/SettingUtils.java b/push/src/main/java/com/xiaomi/xmsf/SettingUtils.java index 16d312ef7..77ab7acf2 100644 --- a/push/src/main/java/com/xiaomi/xmsf/SettingUtils.java +++ b/push/src/main/java/com/xiaomi/xmsf/SettingUtils.java @@ -69,6 +69,12 @@ public static void startMiPushServiceAsForegroundService(Context context) { 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(); @@ -77,6 +83,12 @@ public static void notifyMockNotification(Context context) { 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; 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 417ebdcc5..32a3f1ab7 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 @@ -32,6 +32,9 @@ public class NotificationChannelManager { + /** Dedicated channel for the settings-page replay actions. */ + public static final String DEBUG_CHANNEL_ID = "mipush_debug_test_v2"; + public static NotificationManagerEx getNotificationManagerEx() { return NotificationManagerEx.INSTANCE; } @@ -111,6 +114,29 @@ 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)); + } + private static NotificationChannel createNotificationChannel(PushMetaInfo metaInfo, String packageName, CharSequence appName) { NotificationChannelGroup notificationChannelGroup = createGroupWithPackage(packageName, appName); getNotificationManagerEx().createNotificationChannelGroups( 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 b429076f0..47d7caa59 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 @@ -83,6 +83,8 @@ public class NotificationController { 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. @@ -92,6 +94,17 @@ 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; + } + } + @TargetApi(Build.VERSION_CODES.N) private static void updateSummaryNotification(Context context, PushMetaInfo metaInfo, String packageName, String groupId) { @@ -146,7 +159,14 @@ 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); @@ -195,6 +215,19 @@ public static void publish(Context context, PushMetaInfo metaInfo, int notificat updateSummaryNotification(context, metaInfo, packageName, notification.getGroup()); } + 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; @@ -273,9 +306,15 @@ private static boolean shouldAttachFocusExtras(Context context, PushMetaInfo met CustomConfiguration configuration = XMPushUtils.getConfiguration(metaInfo); CustomConfiguration.FocusNotificationPayload payload = configuration.focusNotificationPayload(); - if (!payload.isUsable() || !isFocusProtocolEnabled(context)) { + 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()) @@ -502,20 +541,44 @@ private static void applyTargetPackage(Context context, Notification notificatio if (notification == null || TextUtils.isEmpty(packageName)) { return; } + boolean targetApplied = false; try { Field field = Notification.class.getDeclaredField("extraNotification"); field.setAccessible(true); Object extraNotification = field.get(notification); if (extraNotification != null) { - Method method = extraNotification.getClass() - .getDeclaredMethod("setTargetPkg", String.class); - method.setAccessible(true); - method.invoke(extraNotification, packageName); - return; + try { + Method method = extraNotification.getClass() + .getDeclaredMethod("setTargetPkg", String.class); + method.setAccessible(true); + method.invoke(extraNotification, packageName); + targetApplied = true; + } 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 { + Method method = extraNotification.getClass() + .getDeclaredMethod("setEnableFloat", boolean.class); + method.setAccessible(true); + method.invoke(extraNotification, + 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( @@ -548,8 +611,7 @@ private static void addFocusNotificationExtras( CustomConfiguration configuration) { CustomConfiguration.FocusNotificationPayload payload = configuration.focusNotificationPayload(); - // Avoid a Settings provider round-trip for ordinary notifications. - if (!payload.isUsable() || !isFocusProtocolEnabled(context)) { + if (!payload.isUsable()) { return; } @@ -558,11 +620,19 @@ private static void addFocusNotificationExtras( focusBundle.putString(FOCUS_PARAM, payload.parameter()); } for (Map.Entry picture : payload.pictureUrls().entrySet()) { - // Supported MIUI SystemUI needs both the URL and the native Icon. + // 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()); } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M + && isFocusProtocolEnabled(context) && !payload.downloadPictureUrls().isEmpty()) { + // 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. focusBundle.putBundle(FOCUS_PICTURES, FocusIconApi23.downloadPictures(context, payload.downloadPictureUrls())); } @@ -890,7 +960,8 @@ private static int getIconId(Context context, String packageName, String resourc public static void test(Context context, String packageName, String title, String description) { - test(context, packageName, title, description, new PushMetaInfo(), 10001); + test(context, packageName, title, description, new PushMetaInfo(), + nextMockNotificationId()); } public static void testFocus(Context context, String packageName, String title, @@ -909,20 +980,40 @@ public static void testFocus(Context context, String packageName, String title, 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, 10002); + 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); } private static void test(Context context, String packageName, String title, String description, PushMetaInfo metaInfo, int notificationId) { - NotificationChannelManager.registerChannelIfNeeded(context, metaInfo, packageName); - - NotificationCompat.Builder localBuilder = new NotificationCompat.Builder(context); + 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); 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 166c5c827..541786420 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 @@ -17,6 +17,7 @@ object NotificationManagerEx { private lateinit var notificationManager: NotificationManager private lateinit var notificationContext: Context private var notificationService: Any? = null + private var packageAttributionSupported: Boolean? = null @JvmField var isHooked: Boolean = false @@ -26,6 +27,7 @@ object NotificationManagerEx { notificationContext = context.applicationContext notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager notificationService = invokeHidden(notificationManager, "getService", emptyArray()).value + packageAttributionSupported = null } fun notify( @@ -38,16 +40,36 @@ object NotificationManagerEx { // even when the public NotificationManager call is used. notification.extras.putString("xmsf_target_package", packageName) } - // Official XMSF uses the normal notify path from Android 10 onward; - // HyperOS attributes it through xmsf_target_package. Older releases - // need the hidden notifyAsPackage bridge when available. - val postedAsPackage = Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && + // 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 { + packageAttributionSupported?.let { return it } + if (!::notificationManager.isInitialized) return false + 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?, diff --git a/push/src/main/res/values-zh/strings.xml b/push/src/main/res/values-zh/strings.xml index 513a0b755..4754f4dd3 100644 --- a/push/src/main/res/values-zh/strings.xml +++ b/push/src/main/res/values-zh/strings.xml @@ -80,7 +80,7 @@ 发送携带 MIUI 官方焦点参数的测试通知,用于兼容性检查。 HyperOS 焦点协议 SystemUI 已提供焦点协议 v%1$d,将转发焦点参数与图片 Bundle。 - SystemUI 未声明焦点协议,测试通知将使用标准 Android 回退样式。 + 系统未声明可选的原生协议,仍会转发原始焦点参数,并保留安全的标准通知回退。 焦点通知测试 焦点参数生成时间: 应用注册时显示通知 diff --git a/push/src/main/res/values/strings.xml b/push/src/main/res/values/strings.xml index c072f8e01..eb8c814be 100644 --- a/push/src/main/res/values/strings.xml +++ b/push/src/main/res/values/strings.xml @@ -85,7 +85,7 @@ 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 focus protocol. Test notifications will use the standard Android fallback. + 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. From b4bdf7fc8b4f8902456cbb93e72102c7ba4a55fa Mon Sep 17 00:00:00 2001 From: SherlockChiang Date: Fri, 21 Aug 2026 22:17:15 +0800 Subject: [PATCH 35/64] fix: forward HyperOS custom focus payload --- .../common/utils/CustomConfiguration.java | 53 ++++++++++++++++++- .../utils/utils/CustomConfigurationTest.java | 47 ++++++++++++++++ .../service/MyMIPushNotificationHelper.java | 2 + .../notification/NotificationController.java | 11 ++++ .../FocusNotificationSafetyTest.java | 8 +++ 5 files changed, 120 insertions(+), 1 deletion(-) 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 751c7ef14..421eeec73 100644 --- a/common/src/main/java/top/trumeet/common/utils/CustomConfiguration.java +++ b/common/src/main/java/top/trumeet/common/utils/CustomConfiguration.java @@ -66,6 +66,12 @@ private static String Config(String name) { 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. */ @@ -296,6 +302,17 @@ 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 @@ -307,6 +324,15 @@ public FocusNotificationPayload focusNotificationPayload() { 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(); @@ -323,7 +349,7 @@ public FocusNotificationPayload focusNotificationPayload() { // bundle is deliberately capped separately by downloadPictureUrls(). pictures.put(entry.getKey(), entry.getValue()); } - return new FocusNotificationPayload(parameter, pictures); + return new FocusNotificationPayload(parameter, customParameter, pictures); } /** Compare digit runs by numeric value so pic_2 sorts before pic_10. */ @@ -430,11 +456,14 @@ private static boolean containsAsciiWhitespace(String value) { 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)); } @@ -444,6 +473,12 @@ 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; } @@ -464,6 +499,7 @@ public Map downloadPictureUrls() { public boolean isUsable() { return (parameter != null && !parameter.trim().isEmpty()) + || (customParameter != null && !customParameter.trim().isEmpty()) || !pictureUrls.isEmpty(); } @@ -481,6 +517,21 @@ public static boolean isParameterWithinLimit(@Nullable String parameter) { <= 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; } 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 dfd22f39c..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 @@ -128,6 +128,53 @@ public void focusPayloadAcceptsParameterWithoutPictures() { 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<>(); 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 bf6f23218..5310610bd 100644 --- a/push/src/main/java/com/xiaomi/push/service/MyMIPushNotificationHelper.java +++ b/push/src/main/java/com/xiaomi/push/service/MyMIPushNotificationHelper.java @@ -651,6 +651,8 @@ private static String getGroupName(Context xmPushService, XmPushActionContainer configuration.focusNotificationPayload(); boolean hasDeliverableFocusPayload = FocusNotificationSafety.isWellFormedParameter(focusPayload.parameter()) + || FocusNotificationSafety.isWellFormedParameter( + focusPayload.customParameter()) || !focusPayload.pictureUrls().isEmpty(); if (FocusNotificationSafety.shouldIsolateFocusGroup( configuredGroup, hasDeliverableFocusPayload)) { 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 47d7caa59..5355bc4ff 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 @@ -79,6 +79,7 @@ public class NotificationController { private static final String NOTIFICATION_SMALL_ICON = "mipush_small_notification"; 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 = @@ -318,6 +319,7 @@ private static boolean shouldAttachFocusExtras(Context context, PushMetaInfo met // 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); @@ -619,6 +621,15 @@ private static void addFocusNotificationExtras( 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 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 index 54db4565f..41f95a839 100644 --- a/push/src/test/java/com/xiaomi/xmsf/push/notification/FocusNotificationSafetyTest.java +++ b/push/src/test/java/com/xiaomi/xmsf/push/notification/FocusNotificationSafetyTest.java @@ -84,6 +84,14 @@ public void malformedFocusParameterIsRejectedBeforePrivateDelivery() { "{\"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 deeplyNestedJsonCannotBreakTheStandardFallback() { String deeplyNested = "[".repeat(1_200) + "0" + "]".repeat(1_200); From a74463151c56183a614809d62084a6e4ee05e471 Mon Sep 17 00:00:00 2001 From: SherlockChiang Date: Sat, 22 Aug 2026 00:15:15 +0800 Subject: [PATCH 36/64] feat: split Xiaomi system focus from portable notifications --- .../common/utils/DeviceFocusPolicy.java | 65 ++++++++ .../utils/utils/DeviceFocusPolicyTest.java | 31 ++++ .../com/nihility/utils/MockMIPushMessage.java | 55 ++++++- .../service/MyMIPushNotificationHelper.java | 12 +- .../notification/FocusNotificationReplay.java | 123 +++++++++++++++ .../notification/NotificationController.java | 148 +++++++++++++++--- .../main/subpage/EventListPageUtils.java | 5 +- .../FocusNotificationReplayTest.java | 68 ++++++++ 8 files changed, 480 insertions(+), 27 deletions(-) create mode 100644 common/src/main/java/top/trumeet/common/utils/DeviceFocusPolicy.java create mode 100644 common/src/test/java/test/top/trumeet/common/utils/utils/DeviceFocusPolicyTest.java create mode 100644 push/src/main/java/com/xiaomi/xmsf/push/notification/FocusNotificationReplay.java create mode 100644 push/src/test/java/com/xiaomi/xmsf/push/notification/FocusNotificationReplayTest.java 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/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..3387924b8 --- /dev/null +++ b/common/src/test/java/test/top/trumeet/common/utils/utils/DeviceFocusPolicyTest.java @@ -0,0 +1,31 @@ +package test.top.trumeet.common.utils.utils; + +import static org.junit.Assert.assertEquals; + +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)); + } +} diff --git a/push/src/main/java/com/nihility/utils/MockMIPushMessage.java b/push/src/main/java/com/nihility/utils/MockMIPushMessage.java index 3bf97b3d7..941a9a60e 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 = container.deepCopy(); + 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/xiaomi/push/service/MyMIPushNotificationHelper.java b/push/src/main/java/com/xiaomi/push/service/MyMIPushNotificationHelper.java index 5310610bd..607e4dcb0 100644 --- a/push/src/main/java/com/xiaomi/push/service/MyMIPushNotificationHelper.java +++ b/push/src/main/java/com/xiaomi/push/service/MyMIPushNotificationHelper.java @@ -413,6 +413,11 @@ private static NotificationCompat.Builder normalStyleNotificationBuilder( 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) { @@ -445,7 +450,8 @@ private static NotificationCompat.Builder normalStyleNotificationBuilder( } notificationBuilder.setStyle(style); } else if (notificationStyle == CustomConfiguration.NotificationStyle.BIG_TEXT - || description.length() > NOTIFICATION_BIG_STYLE_MIN_LEN) { + || description.length() > NOTIFICATION_BIG_STYLE_MIN_LEN + || hasPortableFocusPayload) { NotificationCompat.BigTextStyle style = new NotificationCompat.BigTextStyle(); style.bigText(description); style.setBigContentTitle(title); @@ -650,10 +656,10 @@ private static String getGroupName(Context xmPushService, XmPushActionContainer CustomConfiguration.FocusNotificationPayload focusPayload = configuration.focusNotificationPayload(); boolean hasDeliverableFocusPayload = - FocusNotificationSafety.isWellFormedParameter(focusPayload.parameter()) + (FocusNotificationSafety.isWellFormedParameter(focusPayload.parameter()) || FocusNotificationSafety.isWellFormedParameter( focusPayload.customParameter()) - || !focusPayload.pictureUrls().isEmpty(); + || !focusPayload.pictureUrls().isEmpty()); if (FocusNotificationSafety.shouldIsolateFocusGroup( configuredGroup, hasDeliverableFocusPayload)) { return FocusNotificationSafety.stableFocusGroup(packageName); 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..976e503ff --- /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: Taobao uses a + // string at the top level while its nested protocol uses a number. + // 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/NotificationController.java b/push/src/main/java/com/xiaomi/xmsf/push/notification/NotificationController.java index 5355bc4ff..0193a6246 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 @@ -63,6 +63,7 @@ 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; @@ -304,6 +305,14 @@ private static Notification notify( 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(); @@ -545,16 +554,21 @@ private static void applyTargetPackage(Context context, Notification notificatio } boolean targetApplied = false; try { - Field field = Notification.class.getDeclaredField("extraNotification"); + // 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 { - Method method = extraNotification.getClass() - .getDeclaredMethod("setTargetPkg", String.class); - method.setAccessible(true); - method.invoke(extraNotification, packageName); - targetApplied = true; + targetApplied = invokeMiuiMethod( + extraNotification, "setTargetPkg", packageName); } catch (Throwable ignored) { // Some HyperOS releases expose only part of MiuiNotification. } @@ -565,10 +579,7 @@ private static void applyTargetPackage(Context context, Notification notificatio if (notification.extras != null && notification.extras.containsKey("miui.enableFloat")) { try { - Method method = extraNotification.getClass() - .getDeclaredMethod("setEnableFloat", boolean.class); - method.setAccessible(true); - method.invoke(extraNotification, + invokeMiuiMethod(extraNotification, "setEnableFloat", notification.extras.getBoolean("miui.enableFloat")); } catch (Throwable ignored) { // AOSP has no MiuiNotification setter. @@ -590,6 +601,101 @@ private static void applyTargetPackage(Context context, Notification notificatio } } + 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, @@ -658,19 +764,25 @@ private static boolean isFocusProtocolEnabled(Context context) { return false; } return FOCUS_PROTOCOL_SUPPORT_CACHE.get(SystemClock.elapsedRealtime(), () -> { - int protocolVersion; - try { - protocolVersion = Settings.System.getInt(context.getContentResolver(), - FOCUS_PROTOCOL_SETTING, 0); - } catch (Throwable error) { - logger.w("Unable to read focus-notification protocol setting", error); - return false; - } + 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; 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 17db72651..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 @@ -150,8 +150,9 @@ public static boolean isReplayableEvent(@Nullable Event event) { if (container == null) { return ReplayStatus.INVALID_PAYLOAD; } - MockMIPushMessage.mockProcessMIPushMessage(service, container.deepCopy()); - return ReplayStatus.DISPATCHED; + 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. 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)); + } +} From bcc3e6c0f762d9e9ff502754c609243ab78ce32a Mon Sep 17 00:00:00 2001 From: SherlockChiang Date: Sat, 22 Aug 2026 10:11:42 +0800 Subject: [PATCH 37/64] fix: keep bootstrap notification failure from crashing XMSF --- .../BackgroundActivityStartEnabler.java | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) 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) { From bfff421c8ea7fcc97dcd7350563297c98828a143 Mon Sep 17 00:00:00 2001 From: SherlockChiang Date: Sat, 22 Aug 2026 23:38:51 +0800 Subject: [PATCH 38/64] fix: route ordinary notification clicks to target apps --- push/src/main/AndroidManifest.xml | 22 +++ .../xiaomi/push/sdk/MyPushMessageHandler.java | 13 +- .../service/MyMIPushNotificationHelper.java | 92 +++++++++-- .../java/com/xiaomi/xms/auth/AuthService.java | 149 ++++++++++++++++++ .../notification/FocusNotificationSafety.java | 63 ++++++++ .../notification/NotificationController.java | 114 +++++++++++++- .../main/RecentEventListPage.kt | 6 +- .../service/NotificationExecutorTest.java | 7 +- .../FocusNotificationSafetyTest.java | 25 +++ 9 files changed, 469 insertions(+), 22 deletions(-) create mode 100644 push/src/main/java/com/xiaomi/xms/auth/AuthService.java diff --git a/push/src/main/AndroidManifest.xml b/push/src/main/AndroidManifest.xml index 94239fa3c..84054cf0e 100644 --- a/push/src/main/AndroidManifest.xml +++ b/push/src/main/AndroidManifest.xml @@ -6,7 +6,18 @@ android:name="${mipushReceivePermission}" android:protectionLevel="signature" /> + + + + @@ -178,6 +189,17 @@ android:name=".push.service.HttpService" android:exported="true" /> + + + + + + + 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..ef0e8a119 100644 --- a/push/src/main/java/com/xiaomi/push/sdk/MyPushMessageHandler.java +++ b/push/src/main/java/com/xiaomi/push/sdk/MyPushMessageHandler.java @@ -225,7 +225,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 +247,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/service/MyMIPushNotificationHelper.java b/push/src/main/java/com/xiaomi/push/service/MyMIPushNotificationHelper.java index 607e4dcb0..d3466fd0e 100644 --- a/push/src/main/java/com/xiaomi/push/service/MyMIPushNotificationHelper.java +++ b/push/src/main/java/com/xiaomi/push/service/MyMIPushNotificationHelper.java @@ -748,10 +748,18 @@ private static PendingIntent getClickedPendingIntent( intent.addCategory(String.valueOf(metaInfo.getNotifyId())); CustomConfiguration configuration = XMPushUtils.getConfiguration(metaInfo); + // 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. Intent activityIntent = getSdkIntent(context, container); - // Keep the setting tri-state: an absent key means "use the - // MessagingStyle default", while an explicitly supplied false must - // continue to request the historical service PendingIntent. + if (activityIntent == null) { + activityIntent = getLaunchIntent(context, container.getPackageName()); + } + // 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 compatibility. Boolean explicitSetting = configuration.keys().contains("use_clicked_activity") ? configuration.useClickedActivity(false) : null; @@ -771,22 +779,54 @@ private static PendingIntent getClickedPendingIntent( /** * 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, so - * they may use that path by default. Other notification types retain the - * historical service PendingIntent unless configuration explicitly opts in. + * 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) { // A missing/invalid target can never be upgraded to an Activity - // PendingIntent. The caller supplies only intents already validated by - // getSdkIntent, while this guard keeps the fallback safe for all paths. + // 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; } // Explicit configuration always wins over the MessagingStyle default, - // including an explicit false. - return explicitSetting != null ? explicitSetting : messagingStyle; + // 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; + } } /** @@ -874,6 +914,18 @@ public static Intent getSdkIntent(Context context, XmPushActionContainer contain 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 original action, data, flags and extras + // (QQ mqqwpa and Alipay alipays URIs both rely on them). + intent = makeResolvedActivityExplicit(pkgName, intent, resolvedActivity); + if (intent == null) { + return null; + } //TODO fixit //we don't have RegSecret we cannot decode push action @@ -889,6 +941,26 @@ public static Intent getSdkIntent(Context context, XmPushActionContainer contain 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 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/push/notification/FocusNotificationSafety.java b/push/src/main/java/com/xiaomi/xmsf/push/notification/FocusNotificationSafety.java index 99882994f..d6b26c491 100644 --- a/push/src/main/java/com/xiaomi/xmsf/push/notification/FocusNotificationSafety.java +++ b/push/src/main/java/com/xiaomi/xmsf/push/notification/FocusNotificationSafety.java @@ -15,6 +15,13 @@ */ 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; @@ -98,6 +105,62 @@ public static boolean isWellFormedParameter(String parameter) { } } + /** + * 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); } 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 0193a6246..d06beb555 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 @@ -14,6 +14,7 @@ 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; @@ -292,7 +293,7 @@ private static Notification notify( metaInfo, configuration); if (includeFocusExtras && configuration != null) { - addFocusNotificationExtras(context, notificationBuilder, configuration); + addFocusNotificationExtras(context, packageName, notificationBuilder, configuration); } notificationBuilder.setAutoCancel(true); @@ -715,6 +716,7 @@ private static void applyAlertBehavior( private static void addFocusNotificationExtras( Context context, + String packageName, NotificationCompat.Builder notificationBuilder, CustomConfiguration configuration) { CustomConfiguration.FocusNotificationPayload payload = @@ -742,20 +744,79 @@ private static void addFocusNotificationExtras( // native focus renderer is unavailable. focusBundle.putString(picture.getKey(), picture.getValue()); } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M + 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) - && !payload.downloadPictureUrls().isEmpty()) { + && !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. - focusBundle.putBundle(FOCUS_PICTURES, - FocusIconApi23.downloadPictures(context, payload.downloadPictureUrls())); + 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; @@ -816,6 +877,49 @@ private static ExecutorService createImageExecutor() { 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()) { + return Icon.createWithBitmap(bitmap); + } + } 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()) { + return Icon.createWithBitmap(bitmap); + } + } 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 = 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 7a96921e0..d4dbde999 100644 --- a/push/src/main/java/top/trumeet/mipushframework/main/RecentEventListPage.kt +++ b/push/src/main/java/top/trumeet/mipushframework/main/RecentEventListPage.kt @@ -25,7 +25,11 @@ 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 = MiuixTheme.colorScheme.surfaceContainer.toArgb() diff --git a/push/src/test/java/com/xiaomi/push/service/NotificationExecutorTest.java b/push/src/test/java/com/xiaomi/push/service/NotificationExecutorTest.java index 9c6b4c906..141f13335 100644 --- a/push/src/test/java/com/xiaomi/push/service/NotificationExecutorTest.java +++ b/push/src/test/java/com/xiaomi/push/service/NotificationExecutorTest.java @@ -102,11 +102,12 @@ public void clickedActivitySettingUsesThreeStateContract() { assertTrue(!MyMIPushNotificationHelper.shouldUseActivityClick( Boolean.FALSE, true, activity)); - // An absent setting opts MessagingStyle into the Activity path, while - // non-MessagingStyle notifications retain the service path. + // 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)); - assertTrue(!MyMIPushNotificationHelper.shouldUseActivityClick( + assertTrue(MyMIPushNotificationHelper.shouldUseActivityClick( null, false, activity)); // No resolved target Activity must always use the safe service path, 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 index 41f95a839..89296eec0 100644 --- a/push/src/test/java/com/xiaomi/xmsf/push/notification/FocusNotificationSafetyTest.java +++ b/push/src/test/java/com/xiaomi/xmsf/push/notification/FocusNotificationSafetyTest.java @@ -92,6 +92,31 @@ public void customFocusParameterUsesTheSameBoundedJsonObjectContract() { assertFalse(FocusNotificationSafety.isWellFormedParameter("[]")); } + @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); From 600490a2adba476262d4eb9bac8dc17630a5e1cc Mon Sep 17 00:00:00 2001 From: SherlockChiang Date: Sun, 23 Aug 2026 00:39:51 +0800 Subject: [PATCH 39/64] fix: preserve MiPush click routing and encrypted deep links --- push/src/main/AndroidManifest.xml | 13 ++ .../service/MyMIPushNotificationHelper.java | 206 ++++++++++++++++-- .../xmsf/NotificationClickActivity.java | 133 +++++++++++ push/src/main/res/values/styles.xml | 7 + 4 files changed, 342 insertions(+), 17 deletions(-) create mode 100644 push/src/main/java/com/xiaomi/xmsf/NotificationClickActivity.java diff --git a/push/src/main/AndroidManifest.xml b/push/src/main/AndroidManifest.xml index 84054cf0e..5b7249ea2 100644 --- a/push/src/main/AndroidManifest.xml +++ b/push/src/main/AndroidManifest.xml @@ -68,6 +68,19 @@ android:name=".RemoveDozeActivity" android:exported="true" /> + + + 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 d3466fd0e..349874391 100644 --- a/push/src/main/java/com/xiaomi/push/service/MyMIPushNotificationHelper.java +++ b/push/src/main/java/com/xiaomi/push/service/MyMIPushNotificationHelper.java @@ -48,17 +48,26 @@ 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.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.Set; import java.util.concurrent.ExecutorService; @@ -769,10 +778,26 @@ private static PendingIntent getClickedPendingIntent( return PendingIntent.getService(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); } - activityIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - activityIntent.putExtra("mipush_serviceIntent", intent); - activityIntent.putExtras(intent); - return PendingIntent.getActivity(context, notificationId, activityIntent, + + // A direct target Activity skips the MiPush SDK's notification-click + // callback. Use a transparent XMSF trampoline so every client receives + // the original service intent first, while the sender-provided target + // Activity remains the final destination. This is deliberately generic: + // no package name or vendor Activity is recognized here. + Intent clickTrampoline = new Intent(context, + com.xiaomi.xmsf.NotificationClickActivity.class); + clickTrampoline.putExtra( + com.xiaomi.xmsf.NotificationClickActivity.EXTRA_TARGET_INTENT, + activityIntent); + clickTrampoline.putExtra( + com.xiaomi.xmsf.NotificationClickActivity.EXTRA_SERVICE_INTENT, + intent); + // The payload is already carried by EXTRA_SERVICE_INTENT. Do not copy + // it again into the outer Intent: large focus payloads can otherwise + // exceed Android's Binder transaction limit when SystemUI sends the + // PendingIntent. + clickTrampoline.putExtras(extra); + return PendingIntent.getActivity(context, notificationId, clickTrampoline, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); } @@ -833,20 +858,17 @@ private static Intent getLaunchIntent(Context context, String packageName) { * @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) { + if (context == null || container == null || TextUtils.isEmpty(container.packageName)) { return null; } - - if (!extra.containsKey(PushConstants.EXTRA_PARAM_NOTIFY_EFFECT)) { + String pkgName = container.packageName; + 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); @@ -908,6 +930,16 @@ public static Intent getSdkIntent(Context context, XmPushActionContainer contain } } + // 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 Zhihu/Tieba-style links work + // without package-specific adapters. + Intent payloadIntent = getPayloadRouteIntent(context, container); + if (payloadIntent != null) { + intent = payloadIntent; + } + if (intent != null) { intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); @@ -926,10 +958,6 @@ public static Intent getSdkIntent(Context context, XmPushActionContainer contain if (intent == null) { return null; } - //TODO fixit - - //we don't have RegSecret we cannot decode push action - if (inFetchIntentBlackList(pkgName)) { return null; } @@ -941,6 +969,150 @@ public static Intent getSdkIntent(Context context, XmPushActionContainer contain return null; } + private static final int PAYLOAD_ROUTE_MAX_DEPTH = 8; + private static final int PAYLOAD_ROUTE_MAX_NODES = 256; + private static final int PAYLOAD_ROUTE_MAX_LENGTH = 4096; + + @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_ROUTE_MAX_LENGTH) { + payload = payload.substring(0, PAYLOAD_ROUTE_MAX_LENGTH); + } + int[] nodeCount = new int[]{0}; + if (payload.startsWith("{")) { + return findPayloadRoute(context, container.packageName, + new JSONObject(payload), 0, nodeCount); + } + if (payload.startsWith("[")) { + return findPayloadRoute(context, container.packageName, + new JSONArray(payload), 0, nodeCount); + } + return resolvePayloadRoute(context, container.packageName, 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 " + + container.packageName); + 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); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + 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 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..31a4fd113 --- /dev/null +++ b/push/src/main/java/com/xiaomi/xmsf/NotificationClickActivity.java @@ -0,0 +1,133 @@ +package com.xiaomi.xmsf; + +import android.app.Activity; +import android.content.ActivityNotFoundException; +import android.content.ComponentName; +import android.content.Intent; +import android.os.Bundle; +import android.util.Log; + +import androidx.annotation.Nullable; + +import com.xiaomi.push.sdk.MyPushMessageHandler; +import com.xiaomi.push.service.PushConstants; +import com.xiaomi.xmpush.thrift.XmPushActionContainer; + +import com.nihility.XMPushUtils; + +/** + * 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"; + + private static final String TAG = "MiPushClick"; + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + dispatchClick(getIntent()); + } + + private void dispatchClick(@Nullable Intent clickIntent) { + if (clickIntent == null) { + finish(); + 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 = XMPushUtils.packToContainer(payload); + Intent targetIntent = getParcelable(clickIntent, EXTRA_TARGET_INTENT); + + try { + if (container != null && payload != null) { + // This is the official generic click contract, now executed from + // a user-initiated Activity instead of a background Service. Send + // the complete payload through the target SDK first, then open the + // sender-provided route (or the validated Launcher fallback). + MyPushMessageHandler.forwardToTargetApplication(this, payload); + startTargetActivity(targetIntent, clickIntent, container); + } else { + // A malformed/stale click must still try the validated target route. + startTargetActivity(targetIntent, clickIntent, null); + } + } catch (Throwable error) { + Log.w(TAG, "notification click hand-off failed", error); + try { + // If a target does not expose the MiPush service, the explicit route + // or launcher remains a safe user-visible fallback. + startTargetActivity(targetIntent, clickIntent, container); + } catch (Throwable fallbackError) { + Log.w(TAG, "notification click Activity fallback failed", fallbackError); + } + } finally { + Bundle notificationExtras = serviceIntent == null + ? clickIntent.getExtras() : serviceIntent.getExtras(); + if (container != null && notificationExtras != null) { + try { + MyPushMessageHandler.cancelNotification(this, notificationExtras, container); + } catch (Throwable error) { + Log.w(TAG, "unable to cancel clicked notification", error); + } + } + finish(); + } + } + + private void startTargetActivity( + @Nullable Intent targetIntent, + Intent clickIntent, + @Nullable XmPushActionContainer container) { + Intent launch = targetIntent == null ? null : new Intent(targetIntent); + if (launch == null && container != null && container.getPackageName() != null) { + launch = getPackageManager().getLaunchIntentForPackage(container.getPackageName()); + } + if (launch == null) { + return; + } + + 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); + } catch (ActivityNotFoundException error) { + ComponentName component = launch.getComponent(); + Log.w(TAG, "target Activity not found: " + component, error); + throw error; + } + } + + @SuppressWarnings("deprecation") + @Nullable + private static Intent getParcelable(Intent source, String key) { + return source.getParcelableExtra(key); + } +} 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 + + From c5fd91f79df9d1c4e569fc9261f3507382f0da7f Mon Sep 17 00:00:00 2001 From: SherlockChiang Date: Sun, 23 Aug 2026 00:57:51 +0800 Subject: [PATCH 40/64] fix: preserve official click bridges and parse long deep links --- .../service/MyMIPushNotificationHelper.java | 39 +++++++++++++++---- .../service/NotificationExecutorTest.java | 19 +++++++++ 2 files changed, 50 insertions(+), 8 deletions(-) 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 349874391..7985d7bb1 100644 --- a/push/src/main/java/com/xiaomi/push/service/MyMIPushNotificationHelper.java +++ b/push/src/main/java/com/xiaomi/push/service/MyMIPushNotificationHelper.java @@ -933,12 +933,17 @@ public static Intent getSdkIntent(Context context, XmPushActionContainer contain // 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 Zhihu/Tieba-style links work - // without package-specific adapters. + // this remains app-agnostic and lets clients without a click metadata + // route (for example Zhihu) work without package-specific adapters. + // + // An explicit notify_effect/intent_uri is the sender's official bridge + // contract. Keep it authoritative: proxy Activities such as Tieba's + // XmNotifyActivity consume the complete MiPush click extras, whereas a + // seemingly equivalent URI found inside the payload may bypass that + // bridge and become a no-op. Payload routing is therefore a fallback, + // not an override, whenever the SDK supplied an explicit route. Intent payloadIntent = getPayloadRouteIntent(context, container); - if (payloadIntent != null) { - intent = payloadIntent; - } + intent = chooseClickRoute(intent, payloadIntent); if (intent != null) { @@ -971,7 +976,25 @@ public static Intent getSdkIntent(Context context, XmPushActionContainer contain private static final int PAYLOAD_ROUTE_MAX_DEPTH = 8; private static final int PAYLOAD_ROUTE_MAX_NODES = 256; - private static final int PAYLOAD_ROUTE_MAX_LENGTH = 4096; + /** Maximum size of a single URI/intent route extracted from a payload. */ + private static final int PAYLOAD_ROUTE_MAX_LENGTH = 16 * 1024; + /** + * Do not truncate JSON before parsing it. A truncated document is invalid + * and silently forces a launcher fallback (the Zhihu payloads are commonly + * just over 4 KiB). Reject truly unreasonable documents instead. + */ + 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 explicitSdkRoute != null ? explicitSdkRoute : payloadRoute; + } @Nullable private static Intent getPayloadRouteIntent(Context context, XmPushActionContainer container) { @@ -989,8 +1012,8 @@ private static Intent getPayloadRouteIntent(Context context, XmPushActionContain return null; } String payload = message.getPayload().trim(); - if (payload.length() > PAYLOAD_ROUTE_MAX_LENGTH) { - payload = payload.substring(0, PAYLOAD_ROUTE_MAX_LENGTH); + if (payload.length() > PAYLOAD_DOCUMENT_MAX_LENGTH) { + return null; } int[] nodeCount = new int[]{0}; if (payload.startsWith("{")) { diff --git a/push/src/test/java/com/xiaomi/push/service/NotificationExecutorTest.java b/push/src/test/java/com/xiaomi/push/service/NotificationExecutorTest.java index 141f13335..54bea4174 100644 --- a/push/src/test/java/com/xiaomi/push/service/NotificationExecutorTest.java +++ b/push/src/test/java/com/xiaomi/push/service/NotificationExecutorTest.java @@ -2,6 +2,7 @@ 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; @@ -117,4 +118,22 @@ public void clickedActivitySettingUsesThreeStateContract() { assertTrue(!MyMIPushNotificationHelper.shouldUseActivityClick( null, true, null)); } + + @Test + public void explicitSdkRouteWinsOverPayloadRoute() { + Intent officialBridge = new Intent("official-bridge"); + Intent payloadDeepLink = new Intent("payload-deep-link"); + + // Tieba-style proxy Activities must keep the sender's 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 (such as Zhihu's feed push) + // still get the encrypted payload deep link. + assertSame(payloadDeepLink, MyMIPushNotificationHelper.chooseClickRoute( + null, payloadDeepLink)); + assertSame(null, MyMIPushNotificationHelper.chooseClickRoute(null, null)); + } } From 1472f675a9f322334cde9e9e73fbc9f130941a14 Mon Sep 17 00:00:00 2001 From: SherlockChiang Date: Sun, 23 Aug 2026 01:04:01 +0800 Subject: [PATCH 41/64] fix: prefer payload deep links over generic launchers --- .../xiaomi/push/service/MyMIPushNotificationHelper.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 7985d7bb1..1dad22e8f 100644 --- a/push/src/main/java/com/xiaomi/push/service/MyMIPushNotificationHelper.java +++ b/push/src/main/java/com/xiaomi/push/service/MyMIPushNotificationHelper.java @@ -943,7 +943,12 @@ public static Intent getSdkIntent(Context context, XmPushActionContainer contain // bridge and become a no-op. Payload routing is therefore a fallback, // not an override, whenever the SDK supplied an explicit route. Intent payloadIntent = getPayloadRouteIntent(context, container); - intent = chooseClickRoute(intent, payloadIntent); + // 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 intent/class/web effects remain authoritative. + Intent authoritativeSdkRoute = + PushConstants.NOTIFICATION_CLICK_DEFAULT.equals(typeId) ? null : intent; + intent = chooseClickRoute(authoritativeSdkRoute, payloadIntent); if (intent != null) { From 903c0ce6e9d103457f4bd42661664e29bba9623b Mon Sep 17 00:00:00 2001 From: SherlockChiang Date: Sun, 23 Aug 2026 02:38:26 +0800 Subject: [PATCH 42/64] fix: route private notification clicks safely --- .../service/MyMIPushNotificationHelper.java | 86 +++++++++++++----- .../xmsf/NotificationClickActivity.java | 91 +++++++++++++++++-- 2 files changed, 146 insertions(+), 31 deletions(-) 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 1dad22e8f..2f6a16322 100644 --- a/push/src/main/java/com/xiaomi/push/service/MyMIPushNotificationHelper.java +++ b/push/src/main/java/com/xiaomi/push/service/MyMIPushNotificationHelper.java @@ -779,28 +779,56 @@ private static PendingIntent getClickedPendingIntent( PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); } - // A direct target Activity skips the MiPush SDK's notification-click - // callback. Use a transparent XMSF trampoline so every client receives - // the original service intent first, while the sender-provided target - // Activity remains the final destination. This is deliberately generic: - // no package name or vendor Activity is recognized here. - Intent clickTrampoline = new Intent(context, - com.xiaomi.xmsf.NotificationClickActivity.class); - clickTrampoline.putExtra( - com.xiaomi.xmsf.NotificationClickActivity.EXTRA_TARGET_INTENT, - activityIntent); - clickTrampoline.putExtra( - com.xiaomi.xmsf.NotificationClickActivity.EXTRA_SERVICE_INTENT, - intent); - // The payload is already carried by EXTRA_SERVICE_INTENT. Do not copy - // it again into the outer Intent: large focus payloads can otherwise - // exceed Android's Binder transaction limit when SystemUI sends the - // PendingIntent. - clickTrampoline.putExtras(extra); - return PendingIntent.getActivity(context, notificationId, clickTrampoline, + // 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 the target app's exported + // PushMessageHandler instead; the SDK then starts its private proxy + // from inside the target UID. Exported routes continue to use the + // direct Activity PendingIntent so HyperOS can provide its normal + // conversation/floating-window affordances. + if (!isActivityExported(context, activityIntent)) { + Intent clickTrampoline = new Intent(context, + com.xiaomi.xmsf.NotificationClickActivity.class); + 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, + true); + clickTrampoline.putExtras(extra); + return PendingIntent.getActivity(context, notificationId, clickTrampoline, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); + } + + // Keep the official MiPush click contract for exported routes: launch + // the validated client Activity directly and attach the original + // service Intent under the standard bridge key. This remains + // package-agnostic: only the sender-provided Activity and common + // MiPush service payload are forwarded. + activityIntent.putExtra("mipush_serviceIntent", intent); + activityIntent.putExtras(intent); + return PendingIntent.getActivity(context, notificationId, activityIntent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); } + 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 @@ -938,16 +966,26 @@ public static Intent getSdkIntent(Context context, XmPushActionContainer contain // // An explicit notify_effect/intent_uri is the sender's official bridge // contract. Keep it authoritative: proxy Activities such as Tieba's - // XmNotifyActivity consume the complete MiPush click extras, whereas a - // seemingly equivalent URI found inside the payload may bypass that - // bridge and become a no-op. Payload routing is therefore a fallback, - // not an override, whenever the SDK supplied an explicit route. + // XmNotifyActivity 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 intent/class/web effects remain authoritative. + // 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 + && payloadIntent != null + && !isActivityExported(context, authoritativeSdkRoute) + && isActivityExported(context, payloadIntent)) { + authoritativeSdkRoute = payloadIntent; + } intent = chooseClickRoute(authoritativeSdkRoute, payloadIntent); diff --git a/push/src/main/java/com/xiaomi/xmsf/NotificationClickActivity.java b/push/src/main/java/com/xiaomi/xmsf/NotificationClickActivity.java index 31a4fd113..07c675f52 100644 --- a/push/src/main/java/com/xiaomi/xmsf/NotificationClickActivity.java +++ b/push/src/main/java/com/xiaomi/xmsf/NotificationClickActivity.java @@ -4,6 +4,7 @@ import android.content.ActivityNotFoundException; import android.content.ComponentName; import android.content.Intent; +import android.content.pm.ResolveInfo; import android.os.Bundle; import android.util.Log; @@ -31,6 +32,8 @@ public final class NotificationClickActivity extends Activity { "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"; private static final String TAG = "MiPushClick"; @@ -54,8 +57,17 @@ private void dispatchClick(@Nullable Intent clickIntent) { if (payload == null && serviceIntent != null) { payload = serviceIntent.getByteArrayExtra(PushConstants.MIPUSH_EXTRA_PAYLOAD); } - XmPushActionContainer container = XMPushUtils.packToContainer(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); try { if (container != null && payload != null) { @@ -63,18 +75,31 @@ private void dispatchClick(@Nullable Intent clickIntent) { // a user-initiated Activity instead of a background Service. Send // the complete payload through the target SDK first, then open the // sender-provided route (or the validated Launcher fallback). - MyPushMessageHandler.forwardToTargetApplication(this, payload); - startTargetActivity(targetIntent, clickIntent, container); + boolean forwarded = false; + try { + forwarded = MyPushMessageHandler.forwardToTargetApplication(this, payload) + != null; + if (!forwarded) { + Log.w(TAG, "target SDK click bridge returned no component"); + } + } catch (Throwable forwardError) { + // A private proxy may not expose the MiPush bridge service. + // Continue with the exported route/launcher fallback below. + Log.w(TAG, "target SDK click bridge unavailable", forwardError); + } + if (!targetActivityPrivate || !forwarded) { + startTargetActivity(targetIntent, clickIntent, container, targetActivityPrivate); + } } else { // A malformed/stale click must still try the validated target route. - startTargetActivity(targetIntent, clickIntent, null); + startTargetActivity(targetIntent, clickIntent, null, targetActivityPrivate); } } catch (Throwable error) { Log.w(TAG, "notification click hand-off failed", error); try { // If a target does not expose the MiPush service, the explicit route // or launcher remains a safe user-visible fallback. - startTargetActivity(targetIntent, clickIntent, container); + startTargetActivity(targetIntent, clickIntent, container, targetActivityPrivate); } catch (Throwable fallbackError) { Log.w(TAG, "notification click Activity fallback failed", fallbackError); } @@ -95,8 +120,11 @@ private void dispatchClick(@Nullable Intent clickIntent) { private void startTargetActivity( @Nullable Intent targetIntent, Intent clickIntent, - @Nullable XmPushActionContainer container) { - Intent launch = targetIntent == null ? null : new Intent(targetIntent); + @Nullable XmPushActionContainer container, + boolean targetActivityPrivate) { + Intent launch = targetActivityPrivate + ? resolveExportedFallback(targetIntent, container) + : (targetIntent == null ? null : new Intent(targetIntent)); if (launch == null && container != null && container.getPackageName() != null) { launch = getPackageManager().getLaunchIntentForPackage(container.getPackageName()); } @@ -104,6 +132,11 @@ private void startTargetActivity( return; } + 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"); @@ -125,6 +158,50 @@ private void startTargetActivity( } } + /** + * 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 = container == null ? null : container.getPackageName(); + ComponentName explicit = targetIntent.getComponent(); + if (targetPackage == null && 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) { From 7187f2df60e10a1f1445b0379afdb6acf85d99c1 Mon Sep 17 00:00:00 2001 From: SherlockChiang Date: Sun, 23 Aug 2026 03:17:31 +0800 Subject: [PATCH 43/64] fix: avoid duplicate service wake on activity clicks --- .../push/service/MyMIPushNotificationHelper.java | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) 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 2f6a16322..d5ec6c537 100644 --- a/push/src/main/java/com/xiaomi/push/service/MyMIPushNotificationHelper.java +++ b/push/src/main/java/com/xiaomi/push/service/MyMIPushNotificationHelper.java @@ -85,6 +85,8 @@ */ public class MyMIPushNotificationHelper { + private static final String EXTRA_ACTIVITY_CLICK_PENDING_INTENT = + "com.xiaomi.xmsf.extra.NOTIFICATION_ACTIVITY_CLICK_PENDING_INTENT"; public static final String CLASS_NAME_PUSH_MESSAGE_HANDLER = "com.xiaomi.mipush.sdk.PushMessageHandler"; private static Logger logger = XLog.tag("MyNotificationHelper").build(); @@ -375,7 +377,13 @@ private static NotificationInfo getNotificationFor(Context context, XmPushAction if (localPendingIntent != null) { notificationBuilder.setContentIntent(localPendingIntent); - carryPendingIntentForTemporarilyWhitelisted(context, container, notificationBuilder); + // 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 (!intentExtra.getBooleanExtra(EXTRA_ACTIVITY_CLICK_PENDING_INTENT, false)) { + carryPendingIntentForTemporarilyWhitelisted(context, container, notificationBuilder); + } } return new NotificationInfo(notificationId, notificationBuilder); } @@ -742,6 +750,7 @@ private static PendingIntent getClickedPendingIntent( Intent intent = new Intent("android.intent.action.VIEW"); intent.setData(Uri.parse(urlJump)); intent.addFlags(Intent.FLAG_RECEIVER_FOREGROUND); + extra.putBoolean(EXTRA_ACTIVITY_CLICK_PENDING_INTENT, true); return PendingIntent.getActivity(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); } @@ -800,6 +809,7 @@ private static PendingIntent getClickedPendingIntent( com.xiaomi.xmsf.NotificationClickActivity.EXTRA_TARGET_ACTIVITY_PRIVATE, true); clickTrampoline.putExtras(extra); + extra.putBoolean(EXTRA_ACTIVITY_CLICK_PENDING_INTENT, true); return PendingIntent.getActivity(context, notificationId, clickTrampoline, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); } @@ -811,6 +821,7 @@ private static PendingIntent getClickedPendingIntent( // MiPush service payload are forwarded. activityIntent.putExtra("mipush_serviceIntent", intent); activityIntent.putExtras(intent); + extra.putBoolean(EXTRA_ACTIVITY_CLICK_PENDING_INTENT, true); return PendingIntent.getActivity(context, notificationId, activityIntent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); } From a16c6c821dbf063bbe943b92209453d48a9266a1 Mon Sep 17 00:00:00 2001 From: SherlockChiang Date: Sun, 23 Aug 2026 10:54:21 +0800 Subject: [PATCH 44/64] docs: standardize JDK 17 build workflow --- agent.md | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 agent.md diff --git a/agent.md b/agent.md new file mode 100644 index 000000000..1df63b7e1 --- /dev/null +++ b/agent.md @@ -0,0 +1,50 @@ +# 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. From 76184f339bc06d59f4eeba16716d9ce419f0fb0e Mon Sep 17 00:00:00 2001 From: SherlockChiang Date: Sun, 23 Aug 2026 11:25:39 +0800 Subject: [PATCH 45/64] fix: route focus notification deep links --- .../service/MyMIPushNotificationHelper.java | 64 ++++++++++++++++++- .../service/NotificationExecutorTest.java | 14 ++++ 2 files changed, 76 insertions(+), 2 deletions(-) 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 d5ec6c537..b442d2f11 100644 --- a/push/src/main/java/com/xiaomi/push/service/MyMIPushNotificationHelper.java +++ b/push/src/main/java/com/xiaomi/push/service/MyMIPushNotificationHelper.java @@ -969,6 +969,13 @@ 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; @@ -991,13 +998,19 @@ public static Intent getSdkIntent(Context context, XmPushActionContainer contain // 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, payloadIntent); + intent = chooseClickRoute(authoritativeSdkRoute, focusIntent, payloadIntent); if (intent != null) { @@ -1047,7 +1060,54 @@ && isActivityExported(context, payloadIntent)) { @Nullable static Intent chooseClickRoute( @Nullable Intent explicitSdkRoute, @Nullable Intent payloadRoute) { - return explicitSdkRoute != null ? explicitSdkRoute : 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; + } + + @Nullable + private static Intent getFocusRouteIntent( + Context context, XmPushActionContainer container) { + if (context == null || container == null || TextUtils.isEmpty(container.packageName)) { + 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, container.packageName, 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 diff --git a/push/src/test/java/com/xiaomi/push/service/NotificationExecutorTest.java b/push/src/test/java/com/xiaomi/push/service/NotificationExecutorTest.java index 54bea4174..e76a4e4ee 100644 --- a/push/src/test/java/com/xiaomi/push/service/NotificationExecutorTest.java +++ b/push/src/test/java/com/xiaomi/push/service/NotificationExecutorTest.java @@ -136,4 +136,18 @@ public void explicitSdkRouteWinsOverPayloadRoute() { 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)); + } } From 0fb6df7b0698173a2c5e7bff3ebc09b1d5978206 Mon Sep 17 00:00:00 2001 From: SherlockChiang Date: Mon, 24 Aug 2026 20:44:03 +0800 Subject: [PATCH 46/64] fix: keep inferred notification deep links clean --- .../service/MyMIPushNotificationHelper.java | 66 ++++++++++++++++--- .../service/NotificationExecutorTest.java | 22 +++++++ 2 files changed, 78 insertions(+), 10 deletions(-) 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 b442d2f11..52cd3bc45 100644 --- a/push/src/main/java/com/xiaomi/push/service/MyMIPushNotificationHelper.java +++ b/push/src/main/java/com/xiaomi/push/service/MyMIPushNotificationHelper.java @@ -771,7 +771,8 @@ private static PendingIntent getClickedPendingIntent( // 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. - Intent activityIntent = getSdkIntent(context, container); + ClickRouteResolution clickRoute = resolveSdkClickRoute(context, container); + Intent activityIntent = clickRoute == null ? null : clickRoute.intent; if (activityIntent == null) { activityIntent = getLaunchIntent(context, container.getPackageName()); } @@ -814,18 +815,31 @@ private static PendingIntent getClickedPendingIntent( PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); } - // Keep the official MiPush click contract for exported routes: launch - // the validated client Activity directly and attach the original - // service Intent under the standard bridge key. This remains - // package-agnostic: only the sender-provided Activity and common - // MiPush service payload are forwarded. - activityIntent.putExtra("mipush_serviceIntent", intent); - activityIntent.putExtras(intent); + // 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); + } extra.putBoolean(EXTRA_ACTIVITY_CLICK_PENDING_INTENT, true); return PendingIntent.getActivity(context, notificationId, activityIntent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); } + /** + * 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; + } + private static boolean isActivityExported(Context context, @Nullable Intent activityIntent) { if (context == null || activityIntent == null) { return false; @@ -897,6 +911,13 @@ private static Intent getLaunchIntent(Context context, String packageName) { * @see PushMessageProcessor#getNotificationMessageIntent */ public static Intent getSdkIntent(Context context, XmPushActionContainer container) { + 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(container.packageName)) { return null; } @@ -1014,6 +1035,10 @@ && isActivityExported(context, 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); ResolveInfo resolvedActivity = context.getPackageManager() .resolveActivity(intent, PackageManager.MATCH_DEFAULT_ONLY); @@ -1034,13 +1059,23 @@ && isActivityExported(context, payloadIntent)) { 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. */ @@ -1078,6 +1113,18 @@ static Intent chooseClickRoute( return focusRoute != null ? focusRoute : payloadRoute; } + /** + * 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) { @@ -1238,7 +1285,6 @@ private static Intent resolvePayloadRoute(Context context, String packageName, S intent = new Intent(Intent.ACTION_VIEW, uri); } intent.setPackage(packageName); - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); ResolveInfo resolved = context.getPackageManager() .resolveActivity(intent, PackageManager.MATCH_DEFAULT_ONLY); if (!isResolvedActivityInTargetPackage(packageName, resolved)) { diff --git a/push/src/test/java/com/xiaomi/push/service/NotificationExecutorTest.java b/push/src/test/java/com/xiaomi/push/service/NotificationExecutorTest.java index e76a4e4ee..2d991fc2a 100644 --- a/push/src/test/java/com/xiaomi/push/service/NotificationExecutorTest.java +++ b/push/src/test/java/com/xiaomi/push/service/NotificationExecutorTest.java @@ -150,4 +150,26 @@ public void focusRouteIsPreferredBeforeEncryptedPayloadFallback() { assertTrue(payloadDeepLink == MyMIPushNotificationHelper.chooseClickRoute( null, null, payloadDeepLink)); } + + @Test + public void discoveredDeepLinksDoNotReceiveMiPushBridgeExtras() { + assertTrue(MyMIPushNotificationHelper.shouldAttachMiPushBridgeExtras(false)); + assertTrue(!MyMIPushNotificationHelper.shouldAttachMiPushBridgeExtras(true)); + } + + @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)); + } } From deabf640a96afcc5c48ec525becbc0df628a3fb2 Mon Sep 17 00:00:00 2001 From: SherlockChiang Date: Mon, 24 Aug 2026 21:41:11 +0800 Subject: [PATCH 47/64] fix: preserve sender notification navigation --- README.md | 2 + .../service/MyMIPushNotificationHelper.java | 98 ++++++++++++++++++- .../service/NotificationExecutorTest.java | 87 ++++++++++++++++ 3 files changed, 185 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 865dd9208..618fb6653 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 属于通知对应的应用 - 是否应该安装为系统应用? 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 52cd3bc45..9dde2f16a 100644 --- a/push/src/main/java/com/xiaomi/push/service/MyMIPushNotificationHelper.java +++ b/push/src/main/java/com/xiaomi/push/service/MyMIPushNotificationHelper.java @@ -69,6 +69,7 @@ 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; @@ -123,6 +124,21 @@ 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, @@ -738,6 +754,12 @@ private static PendingIntent getClickedPendingIntent( return null; } + // 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; if (!TextUtils.isEmpty(metaInfo.url)) { @@ -746,7 +768,7 @@ 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); @@ -771,7 +793,8 @@ private static PendingIntent getClickedPendingIntent( // 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 = resolveSdkClickRoute(context, container); + ClickRouteResolution clickRoute = restoredSenderRoute != null + ? restoredSenderRoute : resolveSdkClickRoute(context, container); Intent activityIntent = clickRoute == null ? null : clickRoute.intent; if (activityIntent == null) { activityIntent = getLaunchIntent(context, container.getPackageName()); @@ -840,6 +863,77 @@ static boolean shouldAttachMiPushBridgeExtras(boolean discoveredRoute) { return !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(senderContainer.getPackageName(), + configuredContainer.getPackageName()) + && 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 " + + configuredContainer.getPackageName()); + 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; diff --git a/push/src/test/java/com/xiaomi/push/service/NotificationExecutorTest.java b/push/src/test/java/com/xiaomi/push/service/NotificationExecutorTest.java index 2d991fc2a..643e86dd2 100644 --- a/push/src/test/java/com/xiaomi/push/service/NotificationExecutorTest.java +++ b/push/src/test/java/com/xiaomi/push/service/NotificationExecutorTest.java @@ -6,6 +6,7 @@ import static org.junit.Assert.assertTrue; import com.elvishew.xlog.XLog; +import com.xiaomi.xmpush.thrift.PushMetaInfo; import android.content.pm.ActivityInfo; import android.content.Intent; @@ -14,6 +15,7 @@ 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; @@ -157,6 +159,83 @@ public void discoveredDeepLinksDoNotReceiveMiPushBridgeExtras() { assertTrue(!MyMIPushNotificationHelper.shouldAttachMiPushBridgeExtras(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"); @@ -172,4 +251,12 @@ public void clickRouteOriginUsesSelectedIntentIdentity() { 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; + } } From 79d5630ae1ee1003a3efcb7537e3eb2098e8887c Mon Sep 17 00:00:00 2001 From: SherlockChiang Date: Mon, 24 Aug 2026 23:30:01 +0800 Subject: [PATCH 48/64] refactor: replace package lists with capability checks --- .../MIPushEventProcessorAspectTest.java | 26 +++++++++++--- .../service/MIPushEventProcessorAspect.java | 18 ++++++---- .../service/MyMIPushNotificationHelper.java | 34 +++++-------------- .../notification/FocusNotificationReplay.java | 4 +-- .../main/AppConfigurationUtils.java | 27 +++++---------- push/src/main/res/values/configs.xml | 16 --------- .../MIPushEventProcessorAspectPolicyTest.java | 28 +++++++++++++++ .../service/NotificationExecutorTest.java | 10 +++--- ...pConfigurationUtilsFakeSuggestionTest.java | 16 +++++++++ 9 files changed, 99 insertions(+), 80 deletions(-) delete mode 100644 push/src/main/res/values/configs.xml create mode 100644 push/src/test/java/com/xiaomi/push/service/MIPushEventProcessorAspectPolicyTest.java create mode 100644 push/src/test/java/top/trumeet/mipushframework/main/AppConfigurationUtilsFakeSuggestionTest.java 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/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 9dde2f16a..dde3bf69f 100644 --- a/push/src/main/java/com/xiaomi/push/service/MyMIPushNotificationHelper.java +++ b/push/src/main/java/com/xiaomi/push/service/MyMIPushNotificationHelper.java @@ -1094,12 +1094,12 @@ private static ClickRouteResolution resolveSdkClickRoute( // 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 a click metadata - // route (for example Zhihu) work without package-specific adapters. + // 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: proxy Activities such as Tieba's - // XmNotifyActivity normally consume the complete MiPush click extras. + // 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 @@ -1143,15 +1143,12 @@ && isActivityExported(context, payloadIntent)) { // 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 original action, data, flags and extras - // (QQ mqqwpa and Alipay alipays URIs both rely on them). + // 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; } - if (inFetchIntentBlackList(pkgName)) { - return null; - } return new ClickRouteResolution(intent, discoveredRoute); } @@ -1176,8 +1173,8 @@ private static final class ClickRouteResolution { private static final int PAYLOAD_ROUTE_MAX_LENGTH = 16 * 1024; /** * Do not truncate JSON before parsing it. A truncated document is invalid - * and silently forces a launcher fallback (the Zhihu payloads are commonly - * just over 4 KiB). Reject truly unreasonable documents instead. + * and silently forces a launcher fallback. Reject truly unreasonable + * documents instead. */ private static final int PAYLOAD_DOCUMENT_MAX_LENGTH = 64 * 1024; @@ -1423,21 +1420,6 @@ static boolean isResolvedActivityInTargetPackage(String targetPackage, ResolveIn && targetPackage.equals(resolveInfo.activityInfo.packageName); } - /** - * tmp black list - * - * @param pkg package name - * @return is in black list - */ - private static boolean inFetchIntentBlackList(String pkg) { - if (pkg.contains("youku")) { - return true; - } - - return false; - } - - private static PendingIntent startServicePendingIntent(Context paramContext, XmPushActionContainer paramXmPushActionContainer, PushMetaInfo paramPushMetaInfo, byte[] paramArrayOfByte) { if (paramPushMetaInfo == null) { return null; 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 index 976e503ff..c0c4d84c7 100644 --- a/push/src/main/java/com/xiaomi/xmsf/push/notification/FocusNotificationReplay.java +++ b/push/src/main/java/com/xiaomi/xmsf/push/notification/FocusNotificationReplay.java @@ -66,8 +66,8 @@ private static String refreshParameter( 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: Taobao uses a - // string at the top level while its nested protocol uses a number. + // 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"); 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 98224daa8..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() { 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/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/NotificationExecutorTest.java b/push/src/test/java/com/xiaomi/push/service/NotificationExecutorTest.java index 643e86dd2..3470bb66d 100644 --- a/push/src/test/java/com/xiaomi/push/service/NotificationExecutorTest.java +++ b/push/src/test/java/com/xiaomi/push/service/NotificationExecutorTest.java @@ -126,14 +126,14 @@ public void explicitSdkRouteWinsOverPayloadRoute() { Intent officialBridge = new Intent("official-bridge"); Intent payloadDeepLink = new Intent("payload-deep-link"); - // Tieba-style proxy Activities must keep the sender's official - // intent_uri; a similarly resolvable URI embedded in the payload must - // not bypass that bridge. + // 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 (such as Zhihu's feed push) - // still get the encrypted payload deep link. + // 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)); 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)); + } +} From ab22f2c3f3b31cb230cc457d077dc7396b9feba7 Mon Sep 17 00:00:00 2001 From: SherlockChiang Date: Mon, 24 Aug 2026 23:30:26 +0800 Subject: [PATCH 49/64] fix: derive app status from runtime evidence --- .../trumeet/mipush/provider/db/EventDb.java | 68 ++++++++---- .../provider/db/RegisteredApplicationDb.java | 13 ++- .../db/RegistrationEvidenceResolver.java | 91 +++++++++++++++ .../subpage/ApplicationPageOperation.java | 104 ++++++++++++++++-- push/src/main/res/values-zh/strings.xml | 2 +- push/src/main/res/values/strings.xml | 2 +- .../db/RegistrationEvidenceResolverTest.java | 101 +++++++++++++++++ 7 files changed, 351 insertions(+), 30 deletions(-) create mode 100644 push/src/main/java/top/trumeet/mipush/provider/db/RegistrationEvidenceResolver.java create mode 100644 push/src/test/java/top/trumeet/mipush/provider/db/RegistrationEvidenceResolverTest.java 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/mipushframework/main/subpage/ApplicationPageOperation.java b/push/src/main/java/top/trumeet/mipushframework/main/subpage/ApplicationPageOperation.java index f5f1f9d63..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()); @@ -202,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(); @@ -270,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()); diff --git a/push/src/main/res/values-zh/strings.xml b/push/src/main/res/values-zh/strings.xml index 4754f4dd3..82dbe38dd 100644 --- a/push/src/main/res/values-zh/strings.xml +++ b/push/src/main/res/values-zh/strings.xml @@ -260,7 +260,7 @@ 有问题 未注册 最近接收时间: - 推送服务未找到 + 未检测到标准 MiPush SDK 服务 无法确认推送服务状态 尝试强制注册所有应用 推送服务需要加入“电池优化”白名单才能正常运行。 diff --git a/push/src/main/res/values/strings.xml b/push/src/main/res/values/strings.xml index eb8c814be..570aaabad 100644 --- a/push/src/main/res/values/strings.xml +++ b/push/src/main/res/values/strings.xml @@ -325,7 +325,7 @@ This could be the application doing a reverse registration, or the registration 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 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))); + } +} From 14eb516e0813e45a379618b1fbd2cbd95c28b101 Mon Sep 17 00:00:00 2001 From: SherlockChiang Date: Wed, 26 Aug 2026 22:11:18 +0800 Subject: [PATCH 50/64] fix: replay notification clicks through target SDK --- .../com/nihility/utils/MockMIPushMessage.java | 2 +- .../utils/NotificationReplayMarker.java | 61 +++++ .../push/sdk/TargetSdkClickDispatcher.java | 244 ++++++++++++++++++ .../service/MyMIPushNotificationHelper.java | 36 ++- .../xmsf/NotificationClickActivity.java | 46 +++- .../main/subpage/EventListPage.kt | 71 ++++- .../utils/NotificationReplayMarkerTest.java | 47 ++++ .../sdk/TargetSdkClickDispatcherTest.java | 122 +++++++++ .../service/NotificationExecutorTest.java | 29 ++- .../main/EventListHeaderTextTest.kt | 27 ++ 10 files changed, 654 insertions(+), 31 deletions(-) create mode 100644 push/src/main/java/com/nihility/utils/NotificationReplayMarker.java create mode 100644 push/src/main/java/com/xiaomi/push/sdk/TargetSdkClickDispatcher.java create mode 100644 push/src/test/java/com/nihility/utils/NotificationReplayMarkerTest.java create mode 100644 push/src/test/java/com/xiaomi/push/sdk/TargetSdkClickDispatcherTest.java create mode 100644 push/src/test/java/top/trumeet/mipushframework/main/EventListHeaderTextTest.kt diff --git a/push/src/main/java/com/nihility/utils/MockMIPushMessage.java b/push/src/main/java/com/nihility/utils/MockMIPushMessage.java index 941a9a60e..f964cd747 100644 --- a/push/src/main/java/com/nihility/utils/MockMIPushMessage.java +++ b/push/src/main/java/com/nihility/utils/MockMIPushMessage.java @@ -50,7 +50,7 @@ public static boolean mockProcessMIPushMessage(XMPushService pushService, } static XmPushActionContainer prepareForReplay(XmPushActionContainer container) { - XmPushActionContainer replay = container.deepCopy(); + XmPushActionContainer replay = NotificationReplayMarker.markedCopy(container); PushMetaInfo metaInfo = replay.getMetaInfo(); if (metaInfo == null) { return replay; 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/sdk/TargetSdkClickDispatcher.java b/push/src/main/java/com/xiaomi/push/sdk/TargetSdkClickDispatcher.java new file mode 100644 index 000000000..53ff3c05c --- /dev/null +++ b/push/src/main/java/com/xiaomi/push/sdk/TargetSdkClickDispatcher.java @@ -0,0 +1,244 @@ +package com.xiaomi.push.sdk; + +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 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 used only for historical notification replay. */ +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_STARTED, + BROADCAST_SENT, + UNAVAILABLE, + FAILED; + + public boolean isSuccess() { + return this == SERVICE_STARTED || this == BROADCAST_SENT; + } + } + + 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() { + } + + /** A successful SDK hand-off owns navigation; only failure may open the launcher. */ + public static boolean shouldLaunchReplayFallback(DispatchResult result) { + return result == null || !result.isSuccess(); + } + + 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 + || replayContainer.getPackageName() == null) { + return DispatchResult.UNAVAILABLE; + } + String targetPackage = replayContainer.getPackageName(); + final Capability capability; + try { + capability = selectCapability( + targetPackage, new AndroidCapabilitySource(context.getPackageManager())); + } catch (Throwable ignored) { + return DispatchResult.FAILED; + } + if (capability == null) { + 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; + } + + 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 = targetContainer.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())); + } + return context.startService(targetIntent) == null + ? DispatchResult.FAILED : DispatchResult.SERVICE_STARTED; + } + // 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. + context.sendBroadcast(targetIntent, receiverPermission(targetPackage)); + return DispatchResult.BROADCAST_SENT; + } catch (Throwable ignored) { + return DispatchResult.FAILED; + } + } + + @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/MyMIPushNotificationHelper.java b/push/src/main/java/com/xiaomi/push/service/MyMIPushNotificationHelper.java index dde3bf69f..f6911953a 100644 --- a/push/src/main/java/com/xiaomi/push/service/MyMIPushNotificationHelper.java +++ b/push/src/main/java/com/xiaomi/push/service/MyMIPushNotificationHelper.java @@ -43,6 +43,7 @@ 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; @@ -799,14 +800,21 @@ private static PendingIntent getClickedPendingIntent( if (activityIntent == null) { activityIntent = getLaunchIntent(context, container.getPackageName()); } + 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 compatibility. + // 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); + explicitSetting, messagingStyle, activityIntent, replaySenderRoute); if (!useActivity) { return PendingIntent.getService(context, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); @@ -820,9 +828,11 @@ private static PendingIntent getClickedPendingIntent( // from inside the target UID. Exported routes continue to use the // direct Activity PendingIntent so HyperOS can provide its normal // conversation/floating-window affordances. - if (!isActivityExported(context, activityIntent)) { + boolean targetActivityExported = isActivityExported(context, activityIntent); + if (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); @@ -831,8 +841,10 @@ private static PendingIntent getClickedPendingIntent( intent); clickTrampoline.putExtra( com.xiaomi.xmsf.NotificationClickActivity.EXTRA_TARGET_ACTIVITY_PRIVATE, - true); - clickTrampoline.putExtras(extra); + !targetActivityExported); + clickTrampoline.putExtra( + com.xiaomi.xmsf.NotificationClickActivity.EXTRA_MANUAL_REPLAY, + replaySenderRoute); extra.putBoolean(EXTRA_ACTIVITY_CLICK_PENDING_INTENT, true); return PendingIntent.getActivity(context, notificationId, clickTrampoline, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); @@ -863,6 +875,12 @@ 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 @@ -958,13 +976,19 @@ private static boolean isActivityExported(Context context, @Nullable Intent acti */ static boolean shouldUseActivityClick( @Nullable Boolean explicitSetting, boolean messagingStyle, - @Nullable Intent activityIntent) { + @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; diff --git a/push/src/main/java/com/xiaomi/xmsf/NotificationClickActivity.java b/push/src/main/java/com/xiaomi/xmsf/NotificationClickActivity.java index 07c675f52..0e5ec296d 100644 --- a/push/src/main/java/com/xiaomi/xmsf/NotificationClickActivity.java +++ b/push/src/main/java/com/xiaomi/xmsf/NotificationClickActivity.java @@ -11,6 +11,7 @@ import androidx.annotation.Nullable; import com.xiaomi.push.sdk.MyPushMessageHandler; +import com.xiaomi.push.sdk.TargetSdkClickDispatcher; import com.xiaomi.push.service.PushConstants; import com.xiaomi.xmpush.thrift.XmPushActionContainer; @@ -34,6 +35,8 @@ public final class NotificationClickActivity extends Activity { "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"; private static final String TAG = "MiPushClick"; @@ -68,9 +71,17 @@ private void dispatchClick(@Nullable Intent clickIntent) { Intent targetIntent = getParcelable(clickIntent, EXTRA_TARGET_INTENT); boolean targetActivityPrivate = clickIntent.getBooleanExtra( EXTRA_TARGET_ACTIVITY_PRIVATE, false); + boolean manualReplay = clickIntent.getBooleanExtra(EXTRA_MANUAL_REPLAY, false); try { - if (container != null && payload != null) { + if (manualReplay) { + TargetSdkClickDispatcher.DispatchResult result = + TargetSdkClickDispatcher.dispatchReplay(this, container); + if (TargetSdkClickDispatcher.shouldLaunchReplayFallback(result)) { + Log.w(TAG, "manual replay SDK hand-off failed: " + result); + startTargetLauncher(container); + } + } else if (container != null && payload != null) { // This is the official generic click contract, now executed from // a user-initiated Activity instead of a background Service. Send // the complete payload through the target SDK first, then open the @@ -97,9 +108,13 @@ private void dispatchClick(@Nullable Intent clickIntent) { } catch (Throwable error) { Log.w(TAG, "notification click hand-off failed", error); try { - // If a target does not expose the MiPush service, the explicit route - // or launcher remains a safe user-visible fallback. - startTargetActivity(targetIntent, clickIntent, container, targetActivityPrivate); + if (manualReplay) { + startTargetLauncher(container); + } else { + // Live notifications retain their existing validated route. + startTargetActivity( + targetIntent, clickIntent, container, targetActivityPrivate); + } } catch (Throwable fallbackError) { Log.w(TAG, "notification click Activity fallback failed", fallbackError); } @@ -117,6 +132,29 @@ private void dispatchClick(@Nullable Intent clickIntent) { } } + /** Replay payloads may contain stale vendor bridge tokens; failure opens only the app root. */ + private void startTargetLauncher(@Nullable XmPushActionContainer container) { + String targetPackage = container == null ? null : container.getPackageName(); + if (targetPackage == null) { + return; + } + Intent launch = getPackageManager().getLaunchIntentForPackage(targetPackage); + if (launch == null) { + return; + } + ResolveInfo resolved = getPackageManager().resolveActivity( + launch, android.content.pm.PackageManager.MATCH_DEFAULT_ONLY); + if (resolved == null || resolved.activityInfo == null + || !resolved.activityInfo.exported + || !targetPackage.equals(resolved.activityInfo.packageName)) { + return; + } + launch.setComponent(new ComponentName( + resolved.activityInfo.packageName, resolved.activityInfo.name)); + launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + startActivity(launch); + } + private void startTargetActivity( @Nullable Intent targetIntent, Intent clickIntent, 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 3881cd593..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 @@ -28,6 +28,7 @@ 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 @@ -308,10 +309,15 @@ private fun EventItem(item: EventInfoForDisplay, onClick: (EventInfoForDisplay) AppIcon(item.packageName, item.appName, modifier = Modifier.size(48.dp)) Spacer(Modifier.width(16.dp)) Column(Modifier.weight(1f)) { - Row { - ConfigOptions(item) - ChannelInfo(item) - Spacer(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) @@ -322,23 +328,32 @@ private fun EventItem(item: EventInfoForDisplay, onClick: (EventInfoForDisplay) } @Composable -private fun ConfigOptions(item: EventInfoForDisplay) { - if (item.configOptions.isNotEmpty()) { - Text(item.configOptions.toString(), style = MiuixTheme.textStyles.footnote1) - 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 = MiuixTheme.textStyles.footnote1) +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 = MiuixTheme.textStyles.footnote1) + Text( + text = format.format(item.receiveDate), + style = MiuixTheme.textStyles.footnote1, + maxLines = 1, + softWrap = false, + ) } @Composable @@ -429,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/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..ca44c0596 --- /dev/null +++ b/push/src/test/java/com/xiaomi/push/sdk/TargetSdkClickDispatcherTest.java @@ -0,0 +1,122 @@ +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 onlySuccessfulDispatchSuppressesLauncherFallback() { + assertFalse(TargetSdkClickDispatcher.shouldLaunchReplayFallback( + TargetSdkClickDispatcher.DispatchResult.SERVICE_STARTED)); + assertFalse(TargetSdkClickDispatcher.shouldLaunchReplayFallback( + TargetSdkClickDispatcher.DispatchResult.BROADCAST_SENT)); + assertTrue(TargetSdkClickDispatcher.shouldLaunchReplayFallback( + TargetSdkClickDispatcher.DispatchResult.UNAVAILABLE)); + assertTrue(TargetSdkClickDispatcher.shouldLaunchReplayFallback( + TargetSdkClickDispatcher.DispatchResult.FAILED)); + assertTrue(TargetSdkClickDispatcher.shouldLaunchReplayFallback(null)); + } + + @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/NotificationExecutorTest.java b/push/src/test/java/com/xiaomi/push/service/NotificationExecutorTest.java index 3470bb66d..779ceb4a2 100644 --- a/push/src/test/java/com/xiaomi/push/service/NotificationExecutorTest.java +++ b/push/src/test/java/com/xiaomi/push/service/NotificationExecutorTest.java @@ -101,24 +101,29 @@ public void clickedActivitySettingUsesThreeStateContract() { // Explicit values take precedence over the style-derived default. assertTrue(MyMIPushNotificationHelper.shouldUseActivityClick( - Boolean.TRUE, false, activity)); + Boolean.TRUE, false, activity, false)); assertTrue(!MyMIPushNotificationHelper.shouldUseActivityClick( - Boolean.FALSE, true, activity)); + 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)); + null, true, activity, false)); assertTrue(MyMIPushNotificationHelper.shouldUseActivityClick( - null, false, activity)); + 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)); + Boolean.TRUE, true, null, false)); assertTrue(!MyMIPushNotificationHelper.shouldUseActivityClick( - null, true, null)); + null, true, null, true)); } @Test @@ -159,6 +164,18 @@ public void discoveredDeepLinksDoNotReceiveMiPushBridgeExtras() { 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( 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"), "")) + } +} From b3fd532bab937869b0d975bfe2fff42ceb0dfd3e Mon Sep 17 00:00:00 2001 From: SherlockChiang Date: Thu, 27 Aug 2026 12:06:34 +0800 Subject: [PATCH 51/64] fix: hand off notification clicks after target foreground --- push/src/main/AndroidManifest.xml | 8 +- .../push/sdk/TargetSdkClickDispatcher.java | 113 +++++-- .../service/MyMIPushNotificationHelper.java | 88 +++-- .../xmsf/NotificationClickActivity.java | 314 +++++++++++++++--- .../sdk/TargetSdkClickDispatcherTest.java | 37 ++- .../service/NotificationClickPolicyTest.java | 36 ++ 6 files changed, 484 insertions(+), 112 deletions(-) create mode 100644 push/src/test/java/com/xiaomi/push/service/NotificationClickPolicyTest.java diff --git a/push/src/main/AndroidManifest.xml b/push/src/main/AndroidManifest.xml index 5b7249ea2..c6555daec 100644 --- a/push/src/main/AndroidManifest.xml +++ b/push/src/main/AndroidManifest.xml @@ -70,15 +70,15 @@ 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. The trampoline first + * establishes a visible target task; this dispatcher then uses an immutable, + * one-shot PendingIntent so supported platform/OEM implementations can also + * retain the user-initiated hand-off metadata. Neither mechanism requires + * package-specific routing.

+ */ public final class TargetSdkClickDispatcher { private static final String SDK_SERVICE_CLASS = MyMIPushNotificationHelper.CLASS_NAME_PUSH_MESSAGE_HANDLER; @@ -32,13 +46,15 @@ public final class TargetSdkClickDispatcher { ".permission.MIPUSH_RECEIVE"; public enum DispatchResult { - SERVICE_STARTED, - BROADCAST_SENT, + SERVICE_DELIVERY_ACCEPTED, + BROADCAST_DELIVERY_ACCEPTED, UNAVAILABLE, FAILED; - public boolean isSuccess() { - return this == SERVICE_STARTED || this == BROADCAST_SENT; + /** Delivery acceptance is deliberately not a claim that navigation completed. */ + public boolean isAccepted() { + return this == SERVICE_DELIVERY_ACCEPTED + || this == BROADCAST_DELIVERY_ACCEPTED; } } @@ -80,9 +96,10 @@ interface CapabilitySource { private TargetSdkClickDispatcher() { } - /** A successful SDK hand-off owns navigation; only failure may open the launcher. */ - public static boolean shouldLaunchReplayFallback(DispatchResult result) { - return result == null || !result.isSuccess(); + /** Private and replay routes need a visible target task behind the SDK hand-off. */ + public static boolean shouldPrimeTargetTask( + boolean manualReplay, boolean targetActivityPrivate) { + return manualReplay || targetActivityPrivate; } static String receiverPermission(String targetPackage) { @@ -95,18 +112,6 @@ public static DispatchResult dispatchReplay( || replayContainer.getPackageName() == null) { return DispatchResult.UNAVAILABLE; } - String targetPackage = replayContainer.getPackageName(); - final Capability capability; - try { - capability = selectCapability( - targetPackage, new AndroidCapabilitySource(context.getPackageManager())); - } catch (Throwable ignored) { - return DispatchResult.FAILED; - } - if (capability == null) { - return DispatchResult.UNAVAILABLE; - } - XmPushActionContainer targetContainer = NotificationReplayMarker.copyWithoutMarker(replayContainer); if (targetContainer == null) { @@ -119,13 +124,37 @@ public static DispatchResult dispatchReplay( 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 || container.getPackageName() == null) { + return DispatchResult.UNAVAILABLE; + } + String targetPackage = container.getPackageName(); + 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 = targetContainer.getMetaInfo(); + PushMetaInfo metaInfo = container.getMetaInfo(); try { if (capability.kind == Kind.SERVICE) { @@ -134,21 +163,55 @@ public static DispatchResult dispatchReplay( if (metaInfo != null) { targetIntent.addCategory(String.valueOf(metaInfo.getNotifyId())); } - return context.startService(targetIntent) == null - ? DispatchResult.FAILED : DispatchResult.SERVICE_STARTED; + 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. - context.sendBroadcast(targetIntent, receiverPermission(targetPackage)); - return DispatchResult.BROADCAST_SENT; + 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) { 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 f6911953a..cce44058e 100644 --- a/push/src/main/java/com/xiaomi/push/service/MyMIPushNotificationHelper.java +++ b/push/src/main/java/com/xiaomi/push/service/MyMIPushNotificationHelper.java @@ -87,8 +87,6 @@ */ public class MyMIPushNotificationHelper { - private static final String EXTRA_ACTIVITY_CLICK_PENDING_INTENT = - "com.xiaomi.xmsf.extra.NOTIFICATION_ACTIVITY_CLICK_PENDING_INTENT"; public static final String CLASS_NAME_PUSH_MESSAGE_HANDLER = "com.xiaomi.mipush.sdk.PushMessageHandler"; private static Logger logger = XLog.tag("MyNotificationHelper").build(); @@ -388,17 +386,17 @@ 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( + ClickPendingIntent clickPendingIntent = getClickedPendingIntent( context, container, decryptedContent, notificationId, intentExtra.getExtras(), useMessagingStyle); - if (localPendingIntent != null) { - notificationBuilder.setContentIntent(localPendingIntent); + 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 (!intentExtra.getBooleanExtra(EXTRA_ACTIVITY_CLICK_PENDING_INTENT, false)) { + if (shouldCarryTemporaryWhitelist(clickPendingIntent.activity)) { carryPendingIntentForTemporarilyWhitelisted(context, container, notificationBuilder); } } @@ -747,7 +745,7 @@ private static PendingIntent openActivityPendingIntent(Context paramContext, XmP return null; } - private static PendingIntent getClickedPendingIntent( + private static ClickPendingIntent getClickedPendingIntent( Context context, XmPushActionContainer container, byte[] decryptedContent, int notificationId, Bundle extra, boolean messagingStyle) { PushMetaInfo metaInfo = container.getMetaInfo(); @@ -773,9 +771,9 @@ private static PendingIntent getClickedPendingIntent( Intent intent = new Intent("android.intent.action.VIEW"); intent.setData(Uri.parse(urlJump)); intent.addFlags(Intent.FLAG_RECEIVER_FOREGROUND); - extra.putBoolean(EXTRA_ACTIVITY_CLICK_PENDING_INTENT, true); - return PendingIntent.getActivity(context, notificationId, intent, - PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); + return ClickPendingIntent.activity(PendingIntent.getActivity( + context, notificationId, intent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE)); } Intent intent = new Intent(); @@ -816,20 +814,21 @@ private static PendingIntent getClickedPendingIntent( boolean useActivity = shouldUseActivityClick( explicitSetting, messagingStyle, activityIntent, replaySenderRoute); if (!useActivity) { - return PendingIntent.getService(context, notificationId, intent, - PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); + 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 the target app's exported - // PushMessageHandler instead; the SDK then starts its private proxy - // from inside the target UID. Exported routes continue to use the - // direct Activity PendingIntent so HyperOS can provide its normal - // conversation/floating-window affordances. + // send time. Route those clicks through an isolated user-click hand-off; + // it brings the target task forward before delivering the payload to + // the target SDK. Exported routes continue to use the direct Activity + // PendingIntent so HyperOS can provide its normal conversation and + // floating-window affordances. boolean targetActivityExported = isActivityExported(context, activityIntent); - if (replaySenderRoute || !targetActivityExported) { + if (shouldUseClickTrampoline(replaySenderRoute, targetActivityExported)) { Intent clickTrampoline = new Intent(context, com.xiaomi.xmsf.NotificationClickActivity.class); clickTrampoline.putExtras(extra); @@ -845,9 +844,20 @@ private static PendingIntent getClickedPendingIntent( clickTrampoline.putExtra( com.xiaomi.xmsf.NotificationClickActivity.EXTRA_MANUAL_REPLAY, replaySenderRoute); - extra.putBoolean(EXTRA_ACTIVITY_CLICK_PENDING_INTENT, true); - return PendingIntent.getActivity(context, notificationId, clickTrampoline, - PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); + clickTrampoline.putExtra( + com.xiaomi.xmsf.NotificationClickActivity.EXTRA_TARGET_PACKAGE, + container.getPackageName()); + clickTrampoline.setData(new Uri.Builder() + .scheme("xmsf-notification") + .authority("click") + .appendPath(container.getPackageName()) + .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 @@ -860,9 +870,39 @@ private static PendingIntent getClickedPendingIntent( activityIntent.putExtra("mipush_serviceIntent", intent); activityIntent.putExtras(intent); } - extra.putBoolean(EXTRA_ACTIVITY_CLICK_PENDING_INTENT, true); - return PendingIntent.getActivity(context, notificationId, activityIntent, - PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); + 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; } /** diff --git a/push/src/main/java/com/xiaomi/xmsf/NotificationClickActivity.java b/push/src/main/java/com/xiaomi/xmsf/NotificationClickActivity.java index 0e5ec296d..3655dbf54 100644 --- a/push/src/main/java/com/xiaomi/xmsf/NotificationClickActivity.java +++ b/push/src/main/java/com/xiaomi/xmsf/NotificationClickActivity.java @@ -1,11 +1,18 @@ 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; @@ -17,6 +24,8 @@ import com.nihility.XMPushUtils; +import top.trumeet.common.override.ActivityManagerOverride; + /** * User-initiated notification click hand-off. * @@ -37,8 +46,49 @@ public final class NotificationClickActivity extends Activity { "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 dispatchAfterTargetVisible; + private boolean targetTaskPrimed; + private boolean dispatched; + private boolean stopped; + private long targetLaunchStartedAt; + + private final Runnable targetVisibilityProbe = new Runnable() { + @Override + public void run() { + if (!dispatchAfterTargetVisible || dispatched) { + return; + } + if ((isTargetTaskVisible() || targetTaskPrimed && stopped) + && isUserPresent()) { + completeClick(); + return; + } + if (SystemClock.uptimeMillis() - targetLaunchStartedAt + >= TARGET_VISIBILITY_TIMEOUT_MS) { + abandonAfterTargetLaunch(isUserPresent() + ? "TARGET_UI_TIMEOUT" : "USER_NOT_PRESENT"); + return; + } + mainHandler.postDelayed(this, TARGET_VISIBILITY_POLL_MS); + } + }; @Override protected void onCreate(@Nullable Bundle savedInstanceState) { @@ -46,9 +96,42 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { dispatchClick(getIntent()); } + @Override + protected void onStop() { + super.onStop(); + stopped = true; + // For an ordinary opaque launcher, Android stops this trampoline only + // after the target Activity has become visible. This is the fastest and + // deterministic hand-off point; the bounded probe above covers + // translucent launchers that only pause us. + if (dispatchAfterTargetVisible && !dispatched && isUserPresent()) { + mainHandler.post(targetVisibilityProbe); + } + } + + @Override + protected void onStart() { + super.onStart(); + stopped = false; + } + + @Override + public void onWindowFocusChanged(boolean hasFocus) { + super.onWindowFocusChanged(hasFocus); + if (!hasFocus && dispatchAfterTargetVisible && !dispatched) { + mainHandler.post(targetVisibilityProbe); + } + } + + @Override + protected void onDestroy() { + mainHandler.removeCallbacks(targetVisibilityProbe); + super.onDestroy(); + } + private void dispatchClick(@Nullable Intent clickIntent) { if (clickIntent == null) { - finish(); + finishClickTask(); return; } @@ -73,86 +156,219 @@ private void dispatchClick(@Nullable Intent clickIntent) { 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); + + if (TargetSdkClickDispatcher.shouldPrimeTargetTask( + manualReplay, targetActivityPrivate)) { + if (isTargetTaskVisible() && isUserPresent()) { + completeClick(); + return; + } + targetLaunchStartedAt = SystemClock.uptimeMillis(); + if (!startTargetLauncher(pendingTargetPackage)) { + completeClickWithoutConfirmedTarget("TARGET_LAUNCH_UNAVAILABLE"); + return; + } + targetTaskPrimed = true; + dispatchAfterTargetVisible = true; + mainHandler.post(targetVisibilityProbe); + return; + } + + completeClick(); + } + + private void completeClick() { + if (dispatched) { + return; + } + dispatched = true; + dispatchAfterTargetVisible = false; + mainHandler.removeCallbacks(targetVisibilityProbe); + + Intent clickIntent = pendingClickIntent; + Intent targetIntent = pendingTargetIntent; + byte[] payload = pendingPayload; + XmPushActionContainer container = pendingContainer; + try { - if (manualReplay) { + if (pendingManualReplay) { TargetSdkClickDispatcher.DispatchResult result = TargetSdkClickDispatcher.dispatchReplay(this, container); - if (TargetSdkClickDispatcher.shouldLaunchReplayFallback(result)) { - Log.w(TAG, "manual replay SDK hand-off failed: " + result); - startTargetLauncher(container); + if (!result.isAccepted()) { + Log.w(TAG, "manual replay SDK delivery not accepted: " + result); } - } else if (container != null && payload != null) { - // This is the official generic click contract, now executed from - // a user-initiated Activity instead of a background Service. Send - // the complete payload through the target SDK first, then open the - // sender-provided route (or the validated Launcher fallback). - boolean forwarded = false; - try { - forwarded = MyPushMessageHandler.forwardToTargetApplication(this, payload) - != null; - if (!forwarded) { - Log.w(TAG, "target SDK click bridge returned no component"); + } else if (pendingTargetActivityPrivate && container != null && payload != null) { + TargetSdkClickDispatcher.DispatchResult result = + TargetSdkClickDispatcher.dispatchPayload(this, container, payload); + if (!result.isAccepted()) { + Log.w(TAG, "target SDK click delivery not accepted: " + result); + if (!targetTaskPrimed && clickIntent != null) { + startTargetActivity(targetIntent, clickIntent, container, true); } - } catch (Throwable forwardError) { - // A private proxy may not expose the MiPush bridge service. - // Continue with the exported route/launcher fallback below. - Log.w(TAG, "target SDK click bridge unavailable", forwardError); } - if (!targetActivityPrivate || !forwarded) { - startTargetActivity(targetIntent, clickIntent, container, targetActivityPrivate); - } - } else { + } else if (!targetTaskPrimed && clickIntent != null) { // A malformed/stale click must still try the validated target route. - startTargetActivity(targetIntent, clickIntent, null, targetActivityPrivate); + startTargetActivity( + targetIntent, clickIntent, container, pendingTargetActivityPrivate); + } else if (!pendingTargetActivityPrivate && clickIntent != null) { + // Defensive compatibility for an exported route that was wrapped + // by an older notification already present in the shade. + startTargetActivity(targetIntent, clickIntent, container, false); } } catch (Throwable error) { Log.w(TAG, "notification click hand-off failed", error); try { - if (manualReplay) { - startTargetLauncher(container); - } else { - // Live notifications retain their existing validated route. - startTargetActivity( - targetIntent, clickIntent, container, targetActivityPrivate); + if (!targetTaskPrimed) { + if (pendingManualReplay) { + startTargetLauncher(pendingTargetPackage); + } else if (clickIntent != null) { + startTargetActivity( + targetIntent, clickIntent, container, + pendingTargetActivityPrivate); + } } } catch (Throwable fallbackError) { Log.w(TAG, "notification click Activity fallback failed", fallbackError); } } finally { - Bundle notificationExtras = serviceIntent == null - ? clickIntent.getExtras() : serviceIntent.getExtras(); - if (container != null && notificationExtras != null) { - try { - MyPushMessageHandler.cancelNotification(this, notificationExtras, container); - } catch (Throwable error) { - Log.w(TAG, "unable to cancel clicked notification", error); - } + cancelClickedNotification(); + finishClickTask(); + } + } + + private void completeClickWithoutConfirmedTarget(String reason) { + if (dispatched) { + return; + } + Log.w(TAG, reason + ": delivering payload with isolated-task fallback"); + completeClick(); + } + + private void abandonAfterTargetLaunch(String reason) { + if (dispatched) { + return; + } + dispatched = true; + dispatchAfterTargetVisible = false; + mainHandler.removeCallbacks(targetVisibilityProbe); + Log.w(TAG, reason + ": retaining target launcher fallback without SDK delivery"); + cancelClickedNotification(); + finishClickTask(); + } + + private void cancelClickedNotification() { + 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); } - finish(); } } /** Replay payloads may contain stale vendor bridge tokens; failure opens only the app root. */ - private void startTargetLauncher(@Nullable XmPushActionContainer container) { - String targetPackage = container == null ? null : container.getPackageName(); - if (targetPackage == null) { - return; + private boolean startTargetLauncher(@Nullable String targetPackage) { + if (targetPackage == null || targetPackage.equals(getPackageName())) { + return false; } Intent launch = getPackageManager().getLaunchIntentForPackage(targetPackage); if (launch == null) { - return; + return false; } ResolveInfo resolved = getPackageManager().resolveActivity( - launch, android.content.pm.PackageManager.MATCH_DEFAULT_ONLY); + 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; + return false; } launch.setComponent(new ComponentName( resolved.activityInfo.packageName, resolved.activityInfo.name)); - launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - startActivity(launch); + 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 { + int importance = ActivityManagerOverride.getPackageImportance( + pendingTargetPackage, activityManager); + return importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND + || 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); + if (container != null && container.getPackageName() != null) { + if (trustedPackage != null && !trustedPackage.isEmpty() + && !trustedPackage.equals(container.getPackageName())) { + Log.w(TAG, "target package marker does not match payload"); + return null; + } + return container.getPackageName(); + } + 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() { + if (isTaskRoot()) { + finishAndRemoveTask(); + } else { + finish(); + } } private void startTargetActivity( diff --git a/push/src/test/java/com/xiaomi/push/sdk/TargetSdkClickDispatcherTest.java b/push/src/test/java/com/xiaomi/push/sdk/TargetSdkClickDispatcherTest.java index ca44c0596..56fadc280 100644 --- a/push/src/test/java/com/xiaomi/push/sdk/TargetSdkClickDispatcherTest.java +++ b/push/src/test/java/com/xiaomi/push/sdk/TargetSdkClickDispatcherTest.java @@ -70,16 +70,33 @@ public void receiverChoiceIsDeterministic() { } @Test - public void onlySuccessfulDispatchSuppressesLauncherFallback() { - assertFalse(TargetSdkClickDispatcher.shouldLaunchReplayFallback( - TargetSdkClickDispatcher.DispatchResult.SERVICE_STARTED)); - assertFalse(TargetSdkClickDispatcher.shouldLaunchReplayFallback( - TargetSdkClickDispatcher.DispatchResult.BROADCAST_SENT)); - assertTrue(TargetSdkClickDispatcher.shouldLaunchReplayFallback( - TargetSdkClickDispatcher.DispatchResult.UNAVAILABLE)); - assertTrue(TargetSdkClickDispatcher.shouldLaunchReplayFallback( - TargetSdkClickDispatcher.DispatchResult.FAILED)); - assertTrue(TargetSdkClickDispatcher.shouldLaunchReplayFallback(null)); + 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 privateAndReplayRoutesPrimeTargetTask() { + assertTrue(TargetSdkClickDispatcher.shouldPrimeTargetTask(false, true)); + assertTrue(TargetSdkClickDispatcher.shouldPrimeTargetTask(true, false)); + assertTrue(TargetSdkClickDispatcher.shouldPrimeTargetTask(true, true)); + assertFalse(TargetSdkClickDispatcher.shouldPrimeTargetTask(false, 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 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)); + } +} From a4e7a79ce76d27175ac47d906dcab263768104a0 Mon Sep 17 00:00:00 2001 From: SherlockChiang Date: Thu, 27 Aug 2026 12:56:28 +0800 Subject: [PATCH 52/64] build: use project-owned signing key for app and LSP --- .github/workflows/test_ci.yml | 10 +++++----- .gitignore | 3 +++ agent.md | 9 +++++++++ push/build.gradle | 24 +++++++++++++++--------- 4 files changed, 32 insertions(+), 14 deletions(-) diff --git a/.github/workflows/test_ci.yml b/.github/workflows/test_ci.yml index 9b3014d26..8630cd2ec 100644 --- a/.github/workflows/test_ci.yml +++ b/.github/workflows/test_ci.yml @@ -24,13 +24,13 @@ jobs: - name: Write key run: | - if [ ! -z "${{ secrets.SIGNING_KEY }}" ]; then + if [ ! -z "${{ secrets.MIPUSH_SIGNING_KEY }}" ]; then echo HAS_SIGNING_KEY=true >> $GITHUB_ENV - echo KEYSTORE_PASSWORD='${{ secrets.KEYSTORE_PASSWORD }}' >> local.properties - echo KEYSTORE_ALIAS='${{ secrets.KEYSTORE_ALIAS }}' >> local.properties - echo KEY_PASSWORD='${{ secrets.KEY_PASSWORD }}' >> local.properties + 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 diff --git a/.gitignore b/.gitignore index 00a4dcdff..a83343f63 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,9 @@ /captures .externalNativeBuild .yuuta.jks +*.jks +*.keystore +.mipush-project-signing.env gradle.properties gen/ debug/ diff --git a/agent.md b/agent.md index 1df63b7e1..693c88452 100644 --- a/agent.md +++ b/agent.md @@ -48,3 +48,12 @@ Do not run `clean` routinely; it removes useful incremental outputs and makes th ## 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/push/build.gradle b/push/build.gradle index cde0c2e9a..5e4ee3025 100644 --- a/push/build.gradle +++ b/push/build.gradle @@ -39,16 +39,22 @@ android { 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 = project.rootProject.file(".yuuta.jks") - def keystorePwd = System.getenv("KEYSTORE_PASS") - def alias = System.getenv("ALIAS_NAME") - def pwd = System.getenv("ALIAS_PASS") + 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()) @@ -169,15 +175,15 @@ android.applicationVariants.all { variant -> } gradle.taskGraph.whenReady { taskGraph -> - boolean requiresNihilityKey = taskGraph.allTasks.any { task -> + 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 (requiresNihilityKey) { - def nihilityStore = android.signingConfigs.nihility.storeFile - if (nihilityStore == null || !nihilityStore.exists()) { - throw new GradleException("Official Nihility signing key (.yuuta.jks / KEY_LOCATE) is missing. Release build cannot proceed.") + 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.") } } } From 778aa8df2ed7fd36e8694d31ba8adf4febb4eb6a Mon Sep 17 00:00:00 2001 From: SherlockChiang Date: Thu, 27 Aug 2026 13:12:12 +0800 Subject: [PATCH 53/64] ci: enforce project signing certificate --- .github/workflows/test_ci.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/workflows/test_ci.yml b/.github/workflows/test_ci.yml index 8630cd2ec..601f4f270 100644 --- a/.github/workflows/test_ci.yml +++ b/.github/workflows/test_ci.yml @@ -52,6 +52,18 @@ jobs: 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 build_type in debug release; do From 9ae086027852d19f4e1e70bd0957c973d75cc650 Mon Sep 17 00:00:00 2001 From: SherlockChiang Date: Thu, 27 Aug 2026 13:22:26 +0800 Subject: [PATCH 54/64] docs: attribute KernelSU floating navigation reference --- README.md | 14 ++++++++++++++ .../mipushframework/component/MiuixCompat.kt | 7 +++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 618fb6653..1ee412eb1 100644 --- a/README.md +++ b/README.md @@ -120,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/push/src/main/java/top/trumeet/mipushframework/component/MiuixCompat.kt b/push/src/main/java/top/trumeet/mipushframework/component/MiuixCompat.kt index eb462e13d..d4661d4b3 100644 --- a/push/src/main/java/top/trumeet/mipushframework/component/MiuixCompat.kt +++ b/push/src/main/java/top/trumeet/mipushframework/component/MiuixCompat.kt @@ -246,8 +246,11 @@ fun MiuixBottomNavigation( val indicatorScaleX = 1f + 0.10f * pressProgress val indicatorScaleY = 1f - 0.04f * pressProgress - // Miuix 0.2.x predates FloatingNavigationBar and the blur module used by current - // KernelSU. This compatibility path preserves its 64/4/56/76dp geometry, draggable + // 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. Surface( modifier = modifier From fccba9d2ddaad900ff7961eeabfeed800c5ff071 Mon Sep 17 00:00:00 2001 From: SherlockChiang Date: Fri, 28 Aug 2026 02:56:22 +0800 Subject: [PATCH 55/64] fix: keep notification bridge vendor-specific --- .../utils/utils/DeviceFocusPolicyTest.java | 11 +++++++++++ .../notification/NotificationManagerEx.kt | 19 +++++++++++++++++++ 2 files changed, 30 insertions(+) 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 index 3387924b8..ba4ccc151 100644 --- 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 @@ -1,6 +1,8 @@ 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; @@ -28,4 +30,13 @@ public void nonXiaomiOrMissingProtocolUsesPortableRenderer() { 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/push/src/main/java/com/xiaomi/xmsf/push/notification/NotificationManagerEx.kt b/push/src/main/java/com/xiaomi/xmsf/push/notification/NotificationManagerEx.kt index 541786420..a98de0e7b 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 @@ -10,6 +10,7 @@ import android.os.UserHandle import android.service.notification.StatusBarNotification import androidx.annotation.RequiresApi import com.elvishew.xlog.XLog +import top.trumeet.common.utils.DeviceFocusPolicy object NotificationManagerEx { private const val TAG = "NotificationManagerEx" @@ -61,6 +62,12 @@ object NotificationManagerEx { private fun supportsPackageAttribution(): Boolean { packageAttributionSupported?.let { return it } if (!::notificationManager.isInitialized) return false + // A stale/compatibility Xposed hook must not turn on Xiaomi's hidden + // package-attributed API on Sony, AOSP, or another non-Xiaomi ROM. + if (!DeviceFocusPolicy.isXiaomiManufacturer(Build.MANUFACTURER)) { + packageAttributionSupported = false + return false + } val supported = (invokeHidden( "isSystemConditionProviderEnabled", arrayOf(String::class.java), @@ -212,6 +219,12 @@ object NotificationManagerEx { * 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!!), @@ -343,6 +356,12 @@ object NotificationManagerEx { } 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) } From 6fd44a081e6e06d26ac5929c746aad2d9c4ec8c8 Mon Sep 17 00:00:00 2001 From: SherlockChiang Date: Fri, 28 Aug 2026 18:49:03 +0800 Subject: [PATCH 56/64] fix: keep floating Miuix dock clear of navigation bar --- .../mipushframework/component/MiuixCompat.kt | 10 +- .../trumeet/mipushframework/main/MainPage.kt | 163 +++++++++++------- .../main/subpage/SettingsPage.kt | 5 + 3 files changed, 115 insertions(+), 63 deletions(-) diff --git a/push/src/main/java/top/trumeet/mipushframework/component/MiuixCompat.kt b/push/src/main/java/top/trumeet/mipushframework/component/MiuixCompat.kt index d4661d4b3..98210ced6 100644 --- a/push/src/main/java/top/trumeet/mipushframework/component/MiuixCompat.kt +++ b/push/src/main/java/top/trumeet/mipushframework/component/MiuixCompat.kt @@ -21,8 +21,8 @@ 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.layout.wrapContentWidth import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.selection.selectableGroup import androidx.compose.runtime.Composable @@ -252,9 +252,13 @@ fun MiuixBottomNavigation( // 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 - .wrapContentWidth() + // 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, @@ -263,7 +267,7 @@ fun MiuixBottomNavigation( Box( modifier = Modifier .height(64.dp) - .width(tabWidth * items.size + 8.dp) + .width(panelWidth) .padding(4.dp), contentAlignment = Alignment.CenterStart, ) { 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 0ed84fd7a..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,10 +1,12 @@ 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.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 @@ -14,6 +16,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBarsPadding 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 @@ -72,13 +75,41 @@ class MainPage : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) WindowCompat.setDecorFitsSystemWindows(window, false) + // 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 = MiuixTheme.colorScheme.surfaceContainer.toArgb() 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, @@ -270,75 +301,87 @@ private fun Main( } val swipeThresholdPx = with(LocalDensity.current) { 72.dp.toPx() } - MiuixPageScaffold( - modifier = Modifier.fillMaxSize(), - bottomBar = { - if (floatingBottomNav) { - Box( - modifier = Modifier - .fillMaxWidth() - .navigationBarsPadding() - // Miuix's floating navigation pattern keeps a small breathing room above - // the gesture bar; the bar itself owns its intrinsic width. - .padding(bottom = 12.dp), - contentAlignment = Alignment.BottomCenter, - ) { + 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, - floating = true, + modifier = Modifier.fillMaxWidth(), + floating = false, initialRoute = startDestination, ) } - } else { + }, + ) { 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, - modifier = Modifier.fillMaxWidth(), - floating = false, + floating = true, 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 - ) + } } } 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 ffa1c6ea5..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 @@ -9,8 +9,10 @@ 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 @@ -86,6 +88,9 @@ private fun SettingsScreen( ServiceConfigurationBlock() DebugBlock() AboutBlock() + if (floatingBottomNav) { + Spacer(Modifier.height(100.dp)) + } } } From a642a8023ad6fd4e97cfaba879aae387036eb4fb Mon Sep 17 00:00:00 2001 From: SherlockChiang Date: Fri, 28 Aug 2026 21:50:07 +0800 Subject: [PATCH 57/64] fix: enable portable notification package attribution --- .../notification/NotificationManagerEx.kt | 44 +++++++++++++------ 1 file changed, 30 insertions(+), 14 deletions(-) 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 a98de0e7b..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,11 +6,11 @@ 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 -import top.trumeet.common.utils.DeviceFocusPolicy object NotificationManagerEx { private const val TAG = "NotificationManagerEx" @@ -19,6 +19,10 @@ object NotificationManagerEx { 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 @@ -29,6 +33,7 @@ object NotificationManagerEx { notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager notificationService = invokeHidden(notificationManager, "getService", emptyArray()).value packageAttributionSupported = null + lastCapabilityProbeAt = 0L } fun notify( @@ -60,18 +65,19 @@ object NotificationManagerEx { * calling the hidden API; unsupported/AOSP builds retain the public fallback. */ private fun supportsPackageAttribution(): Boolean { - packageAttributionSupported?.let { return it } + if (packageAttributionSupported == true) return true if (!::notificationManager.isInitialized) return false - // A stale/compatibility Xposed hook must not turn on Xiaomi's hidden - // package-attributed API on Sony, AOSP, or another non-Xiaomi ROM. - if (!DeviceFocusPolicy.isXiaomiManufacturer(Build.MANUFACTURER)) { - packageAttributionSupported = 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") + arrayOf(XMSF_FAKE_CONDITION_PROVIDER_PATH), ).value as? Boolean) == true packageAttributionSupported = supported return supported @@ -87,13 +93,23 @@ object NotificationManagerEx { return false } return try { - val method = notificationManager.javaClass.getDeclaredMethod( - "notifyAsPackage", - String::class.java, - String::class.java, - Int::class.javaPrimitiveType, - Notification::class.java, - ) + 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 From 2e078fb0f995a56ca7eb04f99903a93be58cb228 Mon Sep 17 00:00:00 2001 From: SherlockChiang Date: Sat, 29 Aug 2026 11:45:21 +0800 Subject: [PATCH 58/64] fix: preserve target package notification routing and ordering --- .../xiaomi/push/sdk/MyPushMessageHandler.java | 10 +- .../push/sdk/TargetSdkClickDispatcher.java | 9 +- .../push/service/KeyedSerialDispatcher.java | 385 +++++++++++++++++ .../service/MyMIPushNotificationHelper.java | 162 ++++++-- .../xmsf/NotificationClickActivity.java | 22 +- .../notification/NotificationController.java | 10 +- .../service/KeyedSerialDispatcherTest.java | 389 ++++++++++++++++++ .../service/NotificationExecutorTest.java | 43 ++ 8 files changed, 987 insertions(+), 43 deletions(-) create mode 100644 push/src/main/java/com/xiaomi/push/service/KeyedSerialDispatcher.java create mode 100644 push/src/test/java/com/xiaomi/push/service/KeyedSerialDispatcherTest.java 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 ef0e8a119..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; } diff --git a/push/src/main/java/com/xiaomi/push/sdk/TargetSdkClickDispatcher.java b/push/src/main/java/com/xiaomi/push/sdk/TargetSdkClickDispatcher.java index 21738cee2..b4cc578bc 100644 --- a/push/src/main/java/com/xiaomi/push/sdk/TargetSdkClickDispatcher.java +++ b/push/src/main/java/com/xiaomi/push/sdk/TargetSdkClickDispatcher.java @@ -109,7 +109,8 @@ static String receiverPermission(String targetPackage) { public static DispatchResult dispatchReplay( Context context, @Nullable XmPushActionContainer replayContainer) { if (context == null || replayContainer == null - || replayContainer.getPackageName() == null) { + || MyMIPushNotificationHelper.getNotificationTargetPackage(replayContainer) + .isEmpty()) { return DispatchResult.UNAVAILABLE; } XmPushActionContainer targetContainer = @@ -133,10 +134,12 @@ public static DispatchResult dispatchPayload( @Nullable XmPushActionContainer container, @Nullable byte[] targetPayload) { if (context == null || container == null || targetPayload == null - || targetPayload.length == 0 || container.getPackageName() == null) { + || targetPayload.length == 0 + || MyMIPushNotificationHelper.getNotificationTargetPackage(container).isEmpty()) { return DispatchResult.UNAVAILABLE; } - String targetPackage = container.getPackageName(); + String targetPackage = MyMIPushNotificationHelper + .getNotificationTargetPackage(container); final Capability capability; try { capability = selectCapability( 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/MyMIPushNotificationHelper.java b/push/src/main/java/com/xiaomi/push/service/MyMIPushNotificationHelper.java index cce44058e..844b5f365 100644 --- a/push/src/main/java/com/xiaomi/push/service/MyMIPushNotificationHelper.java +++ b/push/src/main/java/com/xiaomi/push/service/MyMIPushNotificationHelper.java @@ -170,6 +170,19 @@ public class MyMIPushNotificationHelper { */ 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 @@ -218,7 +231,12 @@ public static void notifyPushMessage(Context context, byte[] decryptedContent) { logger.w("Do not notify because user block " + getTargetPackage(container) + "'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); } } @@ -236,7 +254,11 @@ private static void handleNotificationByConfigurations(Context context, byte[] d NotificationDispatchPipeline.dispatch( plan, () -> wakeScreen(appContext, packageName), - () -> executorService.execute(() -> { + // 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(appContext, container, decryptedContent); } catch (Exception e) { @@ -249,6 +271,47 @@ private static void handleNotificationByConfigurations(Context context, byte[] d 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 ""; + } + try { + String targetPackage = getTargetPackage(container); + if (!TextUtils.isEmpty(targetPackage)) { + return targetPackage; + } + } catch (Throwable error) { + logger.w("Unable to derive notification publish package", error); + } + if (!TextUtils.isEmpty(container.getPackageName())) { + 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) { if (!tryLoadConfigurations) { tryLoadConfigurations = true; @@ -332,7 +395,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) { @@ -345,7 +409,7 @@ 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); @@ -360,7 +424,7 @@ private static NotificationInfo getNotificationFor(Context context, XmPushAction notificationBuilder = messagingStyleNotificationBuilder(context, container, notificationId, message, pkgCtx); } else { notificationBuilder = normalStyleNotificationBuilder( - context, container.getPackageName(), container.getMetaInfo()); + context, packageName, container.getMetaInfo()); } if (metaInfo.getExtra() != null) { @@ -413,6 +477,49 @@ 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; try { @@ -511,7 +618,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; @@ -650,7 +757,8 @@ private static Person.Builder getPerson(Context context, PushMetaInfo metaInfo) 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 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); @@ -668,12 +776,12 @@ 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); @@ -734,7 +842,7 @@ 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) { @@ -752,6 +860,7 @@ private static ClickPendingIntent getClickedPendingIntent( 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 @@ -796,7 +905,7 @@ private static ClickPendingIntent getClickedPendingIntent( ? restoredSenderRoute : resolveSdkClickRoute(context, container); Intent activityIntent = clickRoute == null ? null : clickRoute.intent; if (activityIntent == null) { - activityIntent = getLaunchIntent(context, container.getPackageName()); + activityIntent = getLaunchIntent(context, targetPackage); } boolean replaySenderRoute = shouldUseReplayClickTrampoline( NotificationReplayMarker.isMarked(container), @@ -846,11 +955,11 @@ private static ClickPendingIntent getClickedPendingIntent( replaySenderRoute); clickTrampoline.putExtra( com.xiaomi.xmsf.NotificationClickActivity.EXTRA_TARGET_PACKAGE, - container.getPackageName()); + targetPackage); clickTrampoline.setData(new Uri.Builder() .scheme("xmsf-notification") .authority("click") - .appendPath(container.getPackageName()) + .appendPath(targetPackage) .appendPath(Integer.toString(notificationId)) .build()); clickTrampoline.addFlags( @@ -942,8 +1051,8 @@ private static ClickRouteResolution resolveRestoredSenderClickRoute( if (senderContainer != null && configuredContainer != null - && Objects.equals(senderContainer.getPackageName(), - configuredContainer.getPackageName()) + && Objects.equals(publishPackageName(senderContainer), + publishPackageName(configuredContainer)) && shouldPreferSenderClickContract( senderContainer.getMetaInfo(), configuredContainer.getMetaInfo())) { ClickRouteResolution senderRoute = @@ -952,7 +1061,7 @@ && shouldPreferSenderClickContract( && !senderRoute.discoveredRoute && isActivityExported(context, senderRoute.intent)) { logger.d("Restoring sender-declared notification click route for " - + configuredContainer.getPackageName()); + + publishPackageName(configuredContainer)); return senderRoute; } } @@ -1076,10 +1185,11 @@ public static Intent getSdkIntent(Context context, XmPushActionContainer contain @Nullable private static ClickRouteResolution resolveSdkClickRoute( Context context, XmPushActionContainer container) { - if (context == null || container == null || TextUtils.isEmpty(container.packageName)) { + if (context == null || container == null + || TextUtils.isEmpty(publishPackageName(container))) { return null; } - String pkgName = container.packageName; + String pkgName = publishPackageName(container); PushMetaInfo paramPushMetaInfo = container.getMetaInfo(); if (paramPushMetaInfo == null) { return null; @@ -1283,7 +1393,8 @@ static boolean isDiscoveredClickRoute( @Nullable private static Intent getFocusRouteIntent( Context context, XmPushActionContainer container) { - if (context == null || container == null || TextUtils.isEmpty(container.packageName)) { + if (context == null || container == null + || TextUtils.isEmpty(publishPackageName(container))) { return null; } try { @@ -1298,7 +1409,7 @@ private static Intent getFocusRouteIntent( continue; } Intent route = findPayloadRoute( - context, container.packageName, new JSONObject(parameter), 0, + context, publishPackageName(container), new JSONObject(parameter), 0, new int[]{0}); if (route != null) { return route; @@ -1333,19 +1444,19 @@ private static Intent getPayloadRouteIntent(Context context, XmPushActionContain } int[] nodeCount = new int[]{0}; if (payload.startsWith("{")) { - return findPayloadRoute(context, container.packageName, + return findPayloadRoute(context, publishPackageName(container), new JSONObject(payload), 0, nodeCount); } if (payload.startsWith("[")) { - return findPayloadRoute(context, container.packageName, + return findPayloadRoute(context, publishPackageName(container), new JSONArray(payload), 0, nodeCount); } - return resolvePayloadRoute(context, container.packageName, payload); + 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 " - + container.packageName); + + publishPackageName(container)); return null; } } @@ -1494,8 +1605,9 @@ 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); diff --git a/push/src/main/java/com/xiaomi/xmsf/NotificationClickActivity.java b/push/src/main/java/com/xiaomi/xmsf/NotificationClickActivity.java index 3655dbf54..c60e11e21 100644 --- a/push/src/main/java/com/xiaomi/xmsf/NotificationClickActivity.java +++ b/push/src/main/java/com/xiaomi/xmsf/NotificationClickActivity.java @@ -19,6 +19,7 @@ import com.xiaomi.push.sdk.MyPushMessageHandler; import com.xiaomi.push.sdk.TargetSdkClickDispatcher; +import com.xiaomi.push.service.MyMIPushNotificationHelper; import com.xiaomi.push.service.PushConstants; import com.xiaomi.xmpush.thrift.XmPushActionContainer; @@ -345,13 +346,15 @@ private String resolveTargetPackage( @Nullable XmPushActionContainer container, @Nullable Intent targetIntent) { String trustedPackage = clickIntent.getStringExtra(EXTRA_TARGET_PACKAGE); - if (container != null && container.getPackageName() != null) { + String targetPackage = MyMIPushNotificationHelper + .getNotificationTargetPackage(container); + if (container != null && targetPackage != null && !targetPackage.isEmpty()) { if (trustedPackage != null && !trustedPackage.isEmpty() - && !trustedPackage.equals(container.getPackageName())) { + && !trustedPackage.equals(targetPackage)) { Log.w(TAG, "target package marker does not match payload"); return null; } - return container.getPackageName(); + return targetPackage; } if (trustedPackage != null && !trustedPackage.isEmpty()) { return trustedPackage; @@ -379,8 +382,12 @@ private void startTargetActivity( Intent launch = targetActivityPrivate ? resolveExportedFallback(targetIntent, container) : (targetIntent == null ? null : new Intent(targetIntent)); - if (launch == null && container != null && container.getPackageName() != null) { - launch = getPackageManager().getLaunchIntentForPackage(container.getPackageName()); + if (launch == null && container != null) { + String targetPackage = MyMIPushNotificationHelper + .getNotificationTargetPackage(container); + if (targetPackage != null && !targetPackage.isEmpty()) { + launch = getPackageManager().getLaunchIntentForPackage(targetPackage); + } } if (launch == null) { return; @@ -427,9 +434,10 @@ private Intent resolveExportedFallback( return null; } - String targetPackage = container == null ? null : container.getPackageName(); + String targetPackage = MyMIPushNotificationHelper + .getNotificationTargetPackage(container); ComponentName explicit = targetIntent.getComponent(); - if (targetPackage == null && explicit != null) { + if ((targetPackage == null || targetPackage.isEmpty()) && explicit != null) { targetPackage = explicit.getPackageName(); } 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 d06beb555..d0e69d992 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 @@ -38,6 +38,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; @@ -1062,12 +1063,13 @@ 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) { if (notificationGroup != null) { - getNotificationManagerEx().cancel(container.getPackageName(), + getNotificationManagerEx().cancel(packageName, getNotificationTag(container), notificationGroup.hashCode()); } return; @@ -1077,11 +1079,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); } } } 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/NotificationExecutorTest.java b/push/src/test/java/com/xiaomi/push/service/NotificationExecutorTest.java index 779ceb4a2..5651f77d7 100644 --- a/push/src/test/java/com/xiaomi/push/service/NotificationExecutorTest.java +++ b/push/src/test/java/com/xiaomi/push/service/NotificationExecutorTest.java @@ -7,6 +7,7 @@ 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; @@ -55,6 +56,48 @@ public void testThreadFactoryNaming() { 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", From 43c14e31ef4e3d4f421d070fc8f125e59a2c03ff Mon Sep 17 00:00:00 2001 From: SherlockChiang Date: Sat, 29 Aug 2026 13:48:33 +0800 Subject: [PATCH 59/64] feat: add portable focus delivery fallback --- .../service/MyMIPushNotificationHelper.java | 23 +- .../notification/FocusNotificationSafety.java | 261 ++++++++++++++++-- .../notification/NotificationController.java | 36 +++ .../FocusNotificationSafetyTest.java | 103 +++++++ 4 files changed, 397 insertions(+), 26 deletions(-) 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 844b5f365..1ffaa54f1 100644 --- a/push/src/main/java/com/xiaomi/push/service/MyMIPushNotificationHelper.java +++ b/push/src/main/java/com/xiaomi/push/service/MyMIPushNotificationHelper.java @@ -226,9 +226,11 @@ private static java.util.concurrent.ThreadPoolExecutor createNotificationExecuto */ 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); // The SDK uses the wrapper package for some system-delivered @@ -289,15 +291,26 @@ 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(); + } + } try { String targetPackage = getTargetPackage(container); - if (!TextUtils.isEmpty(targetPackage)) { + if (targetPackage != null && !targetPackage.isEmpty()) { return targetPackage; } } catch (Throwable error) { logger.w("Unable to derive notification publish package", error); } - if (!TextUtils.isEmpty(container.getPackageName())) { + if (container.getPackageName() != null && !container.getPackageName().isEmpty()) { return container.getPackageName(); } return ""; @@ -767,7 +780,7 @@ private static void carryPendingIntentForTemporarilyWhitelisted(Context xmPushSe 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(); } 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 index d6b26c491..c1930c363 100644 --- a/push/src/main/java/com/xiaomi/xmsf/push/notification/FocusNotificationSafety.java +++ b/push/src/main/java/com/xiaomi/xmsf/push/notification/FocusNotificationSafety.java @@ -24,9 +24,12 @@ public final class FocusNotificationSafety { 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; 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 String DEFAULT_TITLE = "MiPush notification"; private static final String DEFAULT_BODY = "New notification"; private static final String FOCUS_GROUP_MARKER = "#focus#"; @@ -44,39 +47,100 @@ public static ResolvedContent resolveReadableContent( String focusParameter, String fallbackTitle, String fallbackBody) { - String focusTitle = null; - String focusTicker = null; - String focusBody = null; - if (isParameterWithinLimit(focusParameter)) { - try { - JsonElement root = JsonParser.parseString(focusParameter); - if (root.isJsonObject()) { - JsonObject object = root.getAsJsonObject(); - focusTitle = boundedString(object, "title", - MAX_FALLBACK_TITLE_CODE_POINTS); - focusTicker = boundedString(object, "ticker", - MAX_FALLBACK_TITLE_CODE_POINTS); - focusBody = boundedString(object, "description", - MAX_FALLBACK_BODY_CODE_POINTS); - } - } catch (Throwable ignored) { - // The ordinary notification remains authoritative. - } - } + PortableFocusData portable = parsePortableFocusData(focusParameter); String resolvedTitle = hasText(title) ? title - : firstText(focusTitle, focusTicker, focusBody, + : firstText(portable.title(), portable.body(), sanitizeFallback(fallbackTitle, MAX_FALLBACK_TITLE_CODE_POINTS), DEFAULT_TITLE); String resolvedBody = hasText(body) ? body - : firstText(focusBody, focusTitle, focusTicker, + : 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 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"); + } + + return new PortableFocusData(title, body, url, sequence, progress, + progressCount, Boolean.TRUE.equals(updatable)); + } catch (Throwable ignored) { + return PortableFocusData.EMPTY; + } + } + public static boolean isParameterWithinLimit(String parameter) { if (parameter == null || parameter.length() > MAX_PARAMETER_BYTES) { return false; @@ -238,6 +302,9 @@ private static T rethrow(Throwable 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()) { @@ -246,6 +313,96 @@ private static String boundedString(JsonObject object, String name, int maxCodeP 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; @@ -307,4 +464,66 @@ public String body() { return body; } } + + public static final class PortableFocusData { + private static final PortableFocusData EMPTY = new PortableFocusData( + null, null, null, null, -1, -1, false); + + private final String title; + private final String body; + private final String url; + private final String sequence; + private final int progress; + private final int progressCount; + private final boolean updatable; + + private PortableFocusData( + String title, + String body, + String url, + String sequence, + int progress, + int progressCount, + boolean updatable) { + this.title = title; + this.body = body; + this.url = url; + this.sequence = sequence; + this.progress = progress; + this.progressCount = progressCount; + this.updatable = updatable; + } + + public String title() { + return title; + } + + public String body() { + return body; + } + + public String url() { + return url; + } + + public String sequence() { + return sequence; + } + + public int progress() { + return progress; + } + + public int progressCount() { + return progressCount; + } + + public boolean updatable() { + return updatable; + } + + public boolean hasProgress() { + return progress >= 0; + } + } } 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 d0e69d992..76c7437ad 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 @@ -178,6 +178,9 @@ public static void publish(Context context, PushMetaInfo metaInfo, int notificat notificationBuilder.setPriority(Notification.PRIORITY_HIGH); boolean attemptFocus = shouldAttachFocusExtras(context, metaInfo); + if (!attemptFocus) { + applyPortableFocusPresentation(metaInfo, notificationBuilder); + } if (attemptFocus) { // The official group supplied by the client always wins. Debug and // other direct callers otherwise get a stable focus-only group so a @@ -211,6 +214,9 @@ public static void publish(Context context, PushMetaInfo metaInfo, int notificat focusFailure); } stripFocusNotificationExtras(notificationBuilder); + if (focusFailure != null) { + applyPortableFocusPresentation(metaInfo, notificationBuilder); + } } return notify(context, deliveryId, deliveryPackage, deliveryTag, notificationBuilder, metaInfo, true, includeFocusExtras); @@ -219,6 +225,36 @@ public static void publish(Context context, PushMetaInfo metaInfo, int notificat 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( + PushMetaInfo metaInfo, NotificationCompat.Builder builder) { + if (metaInfo == null || builder == null) { + return; + } + try { + String parameter = XMPushUtils.getConfiguration(metaInfo).focusParam(null); + FocusNotificationSafety.PortableFocusData focus = + FocusNotificationSafety.parsePortableFocusData(parameter); + if (focus.hasProgress()) { + builder.setProgress(FocusNotificationSafety.PORTABLE_PROGRESS_MAX, + focus.progress(), false); + } + 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 hasNoExplicitChannel(NotificationCompat.Builder builder) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { return true; 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 index 89296eec0..b0ec4b2be 100644 --- a/push/src/test/java/com/xiaomi/xmsf/push/notification/FocusNotificationSafetyTest.java +++ b/push/src/test/java/com/xiaomi/xmsf/push/notification/FocusNotificationSafetyTest.java @@ -3,6 +3,7 @@ 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; @@ -92,6 +93,108 @@ public void customFocusParameterUsesTheSameBoundedJsonObjectContract() { 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 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\"," From 3fd5dd779051c0a97682b8aeafb777bfbe483a89 Mon Sep 17 00:00:00 2001 From: SherlockChiang Date: Sat, 29 Aug 2026 19:59:24 +0800 Subject: [PATCH 60/64] feat: add portable delivery focus notifications --- .../notification/FocusNotificationSafety.java | 93 +++++++- .../NotificationChannelManager.java | 69 ++++++ .../notification/NotificationController.java | 203 ++++++++++++++++-- .../notification_focus_point_current.xml | 5 + .../notification_focus_point_done.xml | 5 + .../notification_focus_point_inactive.xml | 5 + .../res/drawable/notification_focus_track.xml | 17 ++ .../layout/notification_focus_portable.xml | 148 +++++++++++++ .../notification_focus_portable_compact.xml | 122 +++++++++++ push/src/main/res/values-night/colors.xml | 8 + push/src/main/res/values-zh/strings.xml | 6 + push/src/main/res/values/colors.xml | 8 + push/src/main/res/values/strings.xml | 7 + .../FocusNotificationSafetyTest.java | 56 +++++ 14 files changed, 734 insertions(+), 18 deletions(-) create mode 100644 push/src/main/res/drawable/notification_focus_point_current.xml create mode 100644 push/src/main/res/drawable/notification_focus_point_done.xml create mode 100644 push/src/main/res/drawable/notification_focus_point_inactive.xml create mode 100644 push/src/main/res/drawable/notification_focus_track.xml create mode 100644 push/src/main/res/layout/notification_focus_portable.xml create mode 100644 push/src/main/res/layout/notification_focus_portable_compact.xml create mode 100644 push/src/main/res/values-night/colors.xml create mode 100644 push/src/main/res/values/colors.xml 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 index c1930c363..055eda58a 100644 --- a/push/src/main/java/com/xiaomi/xmsf/push/notification/FocusNotificationSafety.java +++ b/push/src/main/java/com/xiaomi/xmsf/push/notification/FocusNotificationSafety.java @@ -25,11 +25,20 @@ public final class FocusNotificationSafety { 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#"; @@ -83,6 +92,15 @@ public static PortableFocusData parsePortableFocusData(String focusParameter) { 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)) { @@ -134,8 +152,24 @@ public static PortableFocusData parsePortableFocusData(String focusParameter) { "sequence"); } - return new PortableFocusData(title, body, url, sequence, progress, - progressCount, Boolean.TRUE.equals(updatable)); + // 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; } @@ -467,31 +501,40 @@ public String body() { public static final class PortableFocusData { private static final PortableFocusData EMPTY = new PortableFocusData( - null, null, null, null, -1, -1, false); + 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) { + 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() { @@ -510,6 +553,14 @@ public String sequence() { return sequence; } + public String scene() { + return scene; + } + + public String business() { + return business; + } + public int progress() { return progress; } @@ -522,8 +573,42 @@ 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/NotificationChannelManager.java b/push/src/main/java/com/xiaomi/xmsf/push/notification/NotificationChannelManager.java index 32a3f1ab7..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 @@ -25,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; @@ -34,6 +35,10 @@ 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; @@ -137,6 +142,70 @@ public static void registerDebugChannelIfNeeded(Context context, String packageN 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( 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 76c7437ad..11c49dba7 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 @@ -9,6 +9,7 @@ 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; @@ -24,6 +25,7 @@ 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; @@ -131,7 +133,7 @@ private static void updateSummaryNotification(Context context, PushMetaInfo meta // 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); + builder, metaInfo, false, false, null); } @RequiresApi(api = Build.VERSION_CODES.M) @@ -178,10 +180,21 @@ public static void publish(Context context, PushMetaInfo metaInfo, int notificat notificationBuilder.setPriority(Notification.PRIORITY_HIGH); 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(metaInfo, notificationBuilder); + applyPortableFocusPresentation(portableFocus, notificationBuilder); } - if (attemptFocus) { + 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. @@ -215,11 +228,17 @@ public static void publish(Context context, PushMetaInfo metaInfo, int notificat } stripFocusNotificationExtras(notificationBuilder); if (focusFailure != null) { - applyPortableFocusPresentation(metaInfo, notificationBuilder); + applyPortableFocusPresentation(portableFocus, notificationBuilder); + if (portableFocus.hasProgress() + && portableFocus.isFoodDeliveryTimeline()) { + applyPortableFocusChannel( + context, packageName, notificationBuilder); + } } } return notify(context, deliveryId, deliveryPackage, deliveryTag, - notificationBuilder, metaInfo, true, includeFocusExtras); + notificationBuilder, metaInfo, true, includeFocusExtras, + portableFocus); }); updateSummaryNotification(context, metaInfo, packageName, notification.getGroup()); @@ -233,17 +252,25 @@ public static void publish(Context context, PushMetaInfo metaInfo, int notificat * Android notification. */ private static void applyPortableFocusPresentation( - PushMetaInfo metaInfo, NotificationCompat.Builder builder) { - if (metaInfo == null || builder == null) { + FocusNotificationSafety.PortableFocusData focus, + NotificationCompat.Builder builder) { + if (focus == null || builder == null) { return; } try { - String parameter = XMPushUtils.getConfiguration(metaInfo).focusParam(null); - FocusNotificationSafety.PortableFocusData focus = - FocusNotificationSafety.parsePortableFocusData(parameter); if (focus.hasProgress()) { builder.setProgress(FocusNotificationSafety.PORTABLE_PROGRESS_MAX, - focus.progress(), false); + 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); @@ -255,6 +282,146 @@ private static void applyPortableFocusPresentation( } } + 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; @@ -302,7 +469,8 @@ private static Notification notify( Context context, int notificationId, String packageName, String notificationTag, NotificationCompat.Builder notificationBuilder, PushMetaInfo metaInfo, - boolean includeOfficialMetadata, boolean includeFocusExtras) { + boolean includeOfficialMetadata, boolean includeFocusExtras, + @Nullable FocusNotificationSafety.PortableFocusData portableFocus) { // Make the behavior consistent with official MIUI Bundle extras = new Bundle(); extras.putString("target_package", packageName); @@ -326,9 +494,15 @@ private static Notification notify( } - ensureReadableStandardContent(context, packageName, notificationBuilder, + FocusNotificationSafety.ResolvedContent resolvedContent = + ensureReadableStandardContent(context, packageName, notificationBuilder, metaInfo, configuration); + if (!includeFocusExtras) { + applyPortableFocusRemoteViews( + context, notificationBuilder, portableFocus, resolvedContent); + } + if (includeFocusExtras && configuration != null) { addFocusNotificationExtras(context, packageName, notificationBuilder, configuration); } @@ -405,7 +579,7 @@ private static void stripFocusNotificationExtras(@Nullable Bundle extras) { } } - private static void ensureReadableStandardContent( + private static FocusNotificationSafety.ResolvedContent ensureReadableStandardContent( Context context, String packageName, NotificationCompat.Builder notificationBuilder, @@ -457,6 +631,7 @@ private static void ensureReadableStandardContent( if (!hasReadableText(existingBody)) { notificationBuilder.setContentText(resolved.body()); } + return resolved; } @Nullable 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 82dbe38dd..d80fdb8fa 100644 --- a/push/src/main/res/values-zh/strings.xml +++ b/push/src/main/res/values-zh/strings.xml @@ -78,6 +78,12 @@ 这是一个测试内容 模拟焦点通知 发送携带 MIUI 官方焦点参数的测试通知,用于兼容性检查。 + 焦点通知 + 浮动配送进度更新 + 商家接单 + 骑手配送 + 已送达 + 等待接单 HyperOS 焦点协议 SystemUI 已提供焦点协议 v%1$d,将转发焦点参数与图片 Bundle。 系统未声明可选的原生协议,仍会转发原始焦点参数,并保留安全的标准通知回退。 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/strings.xml b/push/src/main/res/values/strings.xml index 570aaabad..007ce4d2c 100644 --- a/push/src/main/res/values/strings.xml +++ b/push/src/main/res/values/strings.xml @@ -190,6 +190,13 @@ Please grant the Run in the background or wake up permissions. 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 Receive notification message 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 index b0ec4b2be..15cac200c 100644 --- a/push/src/test/java/com/xiaomi/xmsf/push/notification/FocusNotificationSafetyTest.java +++ b/push/src/test/java/com/xiaomi/xmsf/push/notification/FocusNotificationSafetyTest.java @@ -127,6 +127,62 @@ public void parsesNestedProgressAndReadableDeliveryFields() { 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 = From 29b25aa510eaa85e3c5d7c815417621eded7c571 Mon Sep 17 00:00:00 2001 From: SherlockChiang Date: Mon, 31 Aug 2026 14:18:37 +0800 Subject: [PATCH 61/64] fix: provide safe notification icon fallback --- .../service/MyMIPushNotificationHelper.java | 85 ++++++++++-- .../notification/NotificationController.java | 125 +++++++++++++++++- .../service/ConversationIconFallbackTest.java | 37 ++++++ 3 files changed, 233 insertions(+), 14 deletions(-) create mode 100644 push/src/test/java/com/xiaomi/push/service/ConversationIconFallbackTest.java 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 1ffaa54f1..1a037b73c 100644 --- a/push/src/main/java/com/xiaomi/push/service/MyMIPushNotificationHelper.java +++ b/push/src/main/java/com/xiaomi/push/service/MyMIPushNotificationHelper.java @@ -8,6 +8,7 @@ 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; @@ -425,7 +426,8 @@ private static NotificationInfo getNotificationFor(Context context, XmPushAction 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); @@ -643,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; } @@ -688,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); @@ -717,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) { @@ -742,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); @@ -762,11 +780,60 @@ 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. 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 11c49dba7..af368e157 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 @@ -82,6 +82,9 @@ 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"; @@ -494,6 +497,12 @@ private static Notification notify( } + // 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); + FocusNotificationSafety.ResolvedContent resolvedContent = ensureReadableStandardContent(context, packageName, notificationBuilder, metaInfo, configuration); @@ -515,6 +524,63 @@ private static Notification notify( 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 @@ -1109,7 +1175,11 @@ static Icon loadApplicationIcon(Context context, String packageName) { try { Bitmap bitmap = Global.IconCache().getRawIconBitmap(context, packageName); if (bitmap != null && !bitmap.isRecycled()) { - return Icon.createWithBitmap(bitmap); + 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); @@ -1124,7 +1194,11 @@ static Icon loadApplicationIcon(Context context, String packageName) { .getApplicationIcon(packageName); Bitmap bitmap = ImgUtils.drawableToBitmap(drawable); if (bitmap != null && !bitmap.isRecycled()) { - return Icon.createWithBitmap(bitmap); + 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); @@ -1241,10 +1315,51 @@ private static Bitmap downloadPicture(Context context, String url) { 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; } - return largeIcon; + 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; + } + } + + 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) { 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")); + } +} From 86a49f7b09305e955b7975725752bfbe663b5933 Mon Sep 17 00:00:00 2001 From: SherlockChiang Date: Mon, 31 Aug 2026 15:15:33 +0800 Subject: [PATCH 62/64] perf: optimize battery and unify cache trimming --- .../common/cache/ApplicationNameCache.java | 11 ++++ .../top/trumeet/common/cache/IconCache.java | 11 +++- .../common/utils/AlarmSchedulePolicy.java | 17 ++++++- .../utils/utils/AlarmSchedulePolicyTest.java | 12 +++++ .../timers/AlarmManagerTimerAspect.java | 13 ++++- .../AlarmManagerTimerSchedulePolicy.java | 10 +++- .../AlarmManagerTimerSchedulePolicyTest.java | 20 ++++++++ .../com/xiaomi/xmsf/MiPushFrameworkApp.java | 50 +++++++++++++++++++ .../notification/NotificationController.java | 21 ++++++++ .../receivers/NetworkStatusReceiver.java | 32 +++++++++++- .../mipushframework/component/AppIcon.kt | 13 +++++ .../main/ApplicationIconCache.kt | 27 ++++++++-- .../receivers/NetworkStatusReceiverTest.java | 23 +++++++++ 13 files changed, 250 insertions(+), 10 deletions(-) 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 6fa1f68f8..58b5246f4 100644 --- a/common/src/main/java/top/trumeet/common/cache/ApplicationNameCache.java +++ b/common/src/main/java/top/trumeet/common/cache/ApplicationNameCache.java @@ -44,5 +44,16 @@ 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 ba9b1fc5a..fd83887ee 100644 --- a/common/src/main/java/top/trumeet/common/cache/IconCache.java +++ b/common/src/main/java/top/trumeet/common/cache/IconCache.java @@ -91,13 +91,22 @@ IconCompat gen() { * the package icon on demand. */ public void trimMemory(int level) { - if (level >= android.content.ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL) { + // 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)); } } diff --git a/common/src/main/java/top/trumeet/common/utils/AlarmSchedulePolicy.java b/common/src/main/java/top/trumeet/common/utils/AlarmSchedulePolicy.java index 3e399560f..37fba3e98 100644 --- a/common/src/main/java/top/trumeet/common/utils/AlarmSchedulePolicy.java +++ b/common/src/main/java/top/trumeet/common/utils/AlarmSchedulePolicy.java @@ -11,8 +11,23 @@ public enum AlarmScheduleType { } 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 canScheduleExactAlarms + ? AlarmScheduleType.EXACT + : AlarmScheduleType.INEXACT_ALLOW_WHILE_IDLE; } return AlarmScheduleType.EXACT; } 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 index 8ae267e4e..255b11719 100644 --- 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 @@ -27,6 +27,18 @@ public void api31ExactAlarmAllowedReturnsExact() { 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); 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 index f365defb4..8ab72dedd 100644 --- 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 @@ -5,6 +5,7 @@ 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; @@ -36,10 +37,20 @@ public void aroundRegister(ProceedingJoinPoint joinPoint, Object timer, Intent i 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, deadlineMs); + Build.VERSION.SDK_INT, canScheduleExact, powerSaveMode, deadlineMs); int flags = PendingIntent.FLAG_UPDATE_CURRENT; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { 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 index 407ffd5c3..39c07a779 100644 --- 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 @@ -55,8 +55,16 @@ 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); + AlarmSchedulePolicy.determineScheduleType( + sdkInt, canScheduleExactAlarms, powerSaveMode); return new Schedule( scheduleType, ClockType.RTC_WAKEUP, 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 index 027e71f2d..8e2157c76 100644 --- 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 @@ -38,6 +38,26 @@ public void inexactScheduleChangesOnlyPrecisionAndPreservesDeadlineContract() { 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 = diff --git a/push/src/main/java/com/xiaomi/xmsf/MiPushFrameworkApp.java b/push/src/main/java/com/xiaomi/xmsf/MiPushFrameworkApp.java index 0beff1f50..84c42e16a 100644 --- a/push/src/main/java/com/xiaomi/xmsf/MiPushFrameworkApp.java +++ b/push/src/main/java/com/xiaomi/xmsf/MiPushFrameworkApp.java @@ -21,6 +21,7 @@ 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; @@ -29,6 +30,7 @@ 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; @@ -36,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 { @@ -77,6 +80,53 @@ public void onCreate() { } } + @Override + public void onTrimMemory(int level) { + super.onTrimMemory(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); + } + } + + @Override + public void onLowMemory() { + super.onLowMemory(); + try { + Global.IconCache().clearMemory(); + Global.ApplicationNameCache().clearMemory(); + NotificationController.trimMemory( + android.content.ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL); + AppIconKt.clearIconCache(); + } catch (Throwable error) { + logCacheFailure("Unable to clear process caches", error); + } + } + + private void logCacheFailure(String message, Throwable error) { + if (logger != null) { + logger.w(message, error); + } + } + private void requestDozeWhiteList() { try { if (!PushServiceAccessibility.isInDozeWhiteList(this)) { 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 af368e157..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 @@ -114,6 +114,17 @@ public static boolean areNotificationsEnabled(Context context, String packageNam } } + /** + * 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) { @@ -1140,6 +1151,16 @@ protected int sizeOf(String key, Bitmap value) { 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 -> { 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 af37bc4fe..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 @@ -17,10 +17,14 @@ 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) { if (context == null || !PushControllerUtils.isPrefsEnable(context)) { @@ -49,7 +53,8 @@ && claimRecovery(SystemClock.elapsedRealtime())) { try { if (hasNetwork) { PushServiceClient client = PushServiceClient.getInstance(context); - if (client.isProvisioned()) { + if (client.isProvisioned() + && claimRegistrationProcessing(SystemClock.elapsedRealtime())) { client.processRegisterTask(); } } @@ -57,6 +62,31 @@ && claimRecovery(SystemClock.elapsedRealtime())) { } } + 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(); 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 e8f4fa3b0..9b7b7c75f 100644 --- a/push/src/main/java/top/trumeet/mipushframework/component/AppIcon.kt +++ b/push/src/main/java/top/trumeet/mipushframework/component/AppIcon.kt @@ -25,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 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/test/java/com/xiaomi/xmsf/push/service/receivers/NetworkStatusReceiverTest.java b/push/src/test/java/com/xiaomi/xmsf/push/service/receivers/NetworkStatusReceiverTest.java index bdf9afcda..ddca80f09 100644 --- 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 @@ -59,4 +59,27 @@ public void elapsedRealtimeRollbackAllowsRecovery() { 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)); + } } From eb739b2d477457cce13889d22eb28b8c16a478d0 Mon Sep 17 00:00:00 2001 From: SherlockChiang Date: Mon, 31 Aug 2026 15:30:25 +0800 Subject: [PATCH 63/64] refactor: centralize memory pressure cleanup --- .../com/xiaomi/xmsf/MiPushFrameworkApp.java | 24 ++++++++----------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/push/src/main/java/com/xiaomi/xmsf/MiPushFrameworkApp.java b/push/src/main/java/com/xiaomi/xmsf/MiPushFrameworkApp.java index 84c42e16a..dcbab10e1 100644 --- a/push/src/main/java/com/xiaomi/xmsf/MiPushFrameworkApp.java +++ b/push/src/main/java/com/xiaomi/xmsf/MiPushFrameworkApp.java @@ -83,6 +83,16 @@ public void onCreate() { @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 { @@ -107,20 +117,6 @@ public void onTrimMemory(int level) { } } - @Override - public void onLowMemory() { - super.onLowMemory(); - try { - Global.IconCache().clearMemory(); - Global.ApplicationNameCache().clearMemory(); - NotificationController.trimMemory( - android.content.ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL); - AppIconKt.clearIconCache(); - } catch (Throwable error) { - logCacheFailure("Unable to clear process caches", error); - } - } - private void logCacheFailure(String message, Throwable error) { if (logger != null) { logger.w(message, error); From 84e46548a150c837d0d8ef4dc0014256acbdbb95 Mon Sep 17 00:00:00 2001 From: SherlockChiang Date: Wed, 2 Sep 2026 20:16:45 +0800 Subject: [PATCH 64/64] fix: dispatch private notification clicks before launcher fallback --- push/src/main/AndroidManifest.xml | 7 +- .../sdk/NotificationClickHandoffPolicy.java | 48 ++++ .../push/sdk/TargetSdkClickDispatcher.java | 17 +- .../service/MyMIPushNotificationHelper.java | 8 +- .../xmsf/NotificationClickActivity.java | 232 ++++++++++-------- .../sdk/TargetSdkClickDispatcherTest.java | 44 +++- 6 files changed, 229 insertions(+), 127 deletions(-) create mode 100644 push/src/main/java/com/xiaomi/push/sdk/NotificationClickHandoffPolicy.java diff --git a/push/src/main/AndroidManifest.xml b/push/src/main/AndroidManifest.xml index c6555daec..fd518a38b 100644 --- a/push/src/main/AndroidManifest.xml +++ b/push/src/main/AndroidManifest.xml @@ -70,9 +70,10 @@ 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 index b4cc578bc..8603a0e05 100644 --- a/push/src/main/java/com/xiaomi/push/sdk/TargetSdkClickDispatcher.java +++ b/push/src/main/java/com/xiaomi/push/sdk/TargetSdkClickDispatcher.java @@ -33,11 +33,12 @@ * *

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. The trampoline first - * establishes a visible target task; this dispatcher then uses an immutable, - * one-shot PendingIntent so supported platform/OEM implementations can also - * retain the user-initiated hand-off metadata. Neither mechanism requires - * package-specific routing.

+ * 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 = @@ -96,12 +97,6 @@ interface CapabilitySource { private TargetSdkClickDispatcher() { } - /** Private and replay routes need a visible target task behind the SDK hand-off. */ - public static boolean shouldPrimeTargetTask( - boolean manualReplay, boolean targetActivityPrivate) { - return manualReplay || targetActivityPrivate; - } - static String receiverPermission(String targetPackage) { return targetPackage == null ? null : targetPackage + RECEIVER_PERMISSION_SUFFIX; } 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 1a037b73c..3b5a25292 100644 --- a/push/src/main/java/com/xiaomi/push/service/MyMIPushNotificationHelper.java +++ b/push/src/main/java/com/xiaomi/push/service/MyMIPushNotificationHelper.java @@ -1012,10 +1012,10 @@ private static ClickPendingIntent getClickedPendingIntent( // 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 brings the target task forward before delivering the payload to - // the target SDK. Exported routes continue to use the direct Activity - // PendingIntent so HyperOS can provide its normal conversation and - // floating-window affordances. + // 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, diff --git a/push/src/main/java/com/xiaomi/xmsf/NotificationClickActivity.java b/push/src/main/java/com/xiaomi/xmsf/NotificationClickActivity.java index c60e11e21..b87dfeee7 100644 --- a/push/src/main/java/com/xiaomi/xmsf/NotificationClickActivity.java +++ b/push/src/main/java/com/xiaomi/xmsf/NotificationClickActivity.java @@ -18,6 +18,7 @@ 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; @@ -25,6 +26,8 @@ import com.nihility.XMPushUtils; +import java.util.List; + import top.trumeet.common.override.ActivityManagerOverride; /** @@ -64,30 +67,33 @@ public final class NotificationClickActivity extends Activity { @Nullable private String pendingTargetPackage; private boolean pendingTargetActivityPrivate; private boolean pendingManualReplay; - private boolean dispatchAfterTargetVisible; - private boolean targetTaskPrimed; - private boolean dispatched; + private boolean waitingForTargetNavigation; + private boolean sdkDispatchAttempted; + private boolean clickFinished; + private boolean notificationCancelled; private boolean stopped; - private long targetLaunchStartedAt; + private long sdkDispatchStartedAt; private final Runnable targetVisibilityProbe = new Runnable() { @Override public void run() { - if (!dispatchAfterTargetVisible || dispatched) { - return; - } - if ((isTargetTaskVisible() || targetTaskPrimed && stopped) - && isUserPresent()) { - completeClick(); + if (!waitingForTargetNavigation || clickFinished) { return; } - if (SystemClock.uptimeMillis() - targetLaunchStartedAt - >= TARGET_VISIBILITY_TIMEOUT_MS) { - abandonAfterTargetLaunch(isUserPresent() - ? "TARGET_UI_TIMEOUT" : "USER_NOT_PRESENT"); - 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); } - mainHandler.postDelayed(this, TARGET_VISIBILITY_POLL_MS); } }; @@ -101,11 +107,9 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { protected void onStop() { super.onStop(); stopped = true; - // For an ordinary opaque launcher, Android stops this trampoline only - // after the target Activity has become visible. This is the fastest and - // deterministic hand-off point; the bounded probe above covers - // translucent launchers that only pause us. - if (dispatchAfterTargetVisible && !dispatched && isUserPresent()) { + // 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); } } @@ -119,7 +123,7 @@ protected void onStart() { @Override public void onWindowFocusChanged(boolean hasFocus) { super.onWindowFocusChanged(hasFocus); - if (!hasFocus && dispatchAfterTargetVisible && !dispatched) { + if (!hasFocus && waitingForTargetNavigation && !clickFinished) { mainHandler.post(targetVisibilityProbe); } } @@ -166,106 +170,112 @@ private void dispatchClick(@Nullable Intent clickIntent) { pendingManualReplay = manualReplay; pendingTargetPackage = resolveTargetPackage(clickIntent, container, targetIntent); - if (TargetSdkClickDispatcher.shouldPrimeTargetTask( - manualReplay, targetActivityPrivate)) { - if (isTargetTaskVisible() && isUserPresent()) { - completeClick(); - return; - } - targetLaunchStartedAt = SystemClock.uptimeMillis(); - if (!startTargetLauncher(pendingTargetPackage)) { - completeClickWithoutConfirmedTarget("TARGET_LAUNCH_UNAVAILABLE"); - return; - } - targetTaskPrimed = true; - dispatchAfterTargetVisible = true; - mainHandler.post(targetVisibilityProbe); - return; + NotificationClickHandoffPolicy.Action initialAction = + NotificationClickHandoffPolicy.initialAction( + manualReplay, targetActivityPrivate); + if (initialAction == NotificationClickHandoffPolicy.Action.DISPATCH_SDK_FIRST) { + dispatchSdkFirst(); + } else { + startDirectTargetAndFinish(); } - - completeClick(); } - private void completeClick() { - if (dispatched) { + private void dispatchSdkFirst() { + if (sdkDispatchAttempted || clickFinished) { return; } - dispatched = true; - dispatchAfterTargetVisible = false; - mainHandler.removeCallbacks(targetVisibilityProbe); - - Intent clickIntent = pendingClickIntent; - Intent targetIntent = pendingTargetIntent; - byte[] payload = pendingPayload; - XmPushActionContainer container = pendingContainer; + sdkDispatchAttempted = true; + TargetSdkClickDispatcher.DispatchResult result = + TargetSdkClickDispatcher.DispatchResult.UNAVAILABLE; try { if (pendingManualReplay) { - TargetSdkClickDispatcher.DispatchResult result = - TargetSdkClickDispatcher.dispatchReplay(this, container); - if (!result.isAccepted()) { - Log.w(TAG, "manual replay SDK delivery not accepted: " + result); - } - } else if (pendingTargetActivityPrivate && container != null && payload != null) { - TargetSdkClickDispatcher.DispatchResult result = - TargetSdkClickDispatcher.dispatchPayload(this, container, payload); - if (!result.isAccepted()) { - Log.w(TAG, "target SDK click delivery not accepted: " + result); - if (!targetTaskPrimed && clickIntent != null) { - startTargetActivity(targetIntent, clickIntent, container, true); - } - } - } else if (!targetTaskPrimed && clickIntent != null) { - // A malformed/stale click must still try the validated target route. - startTargetActivity( - targetIntent, clickIntent, container, pendingTargetActivityPrivate); - } else if (!pendingTargetActivityPrivate && clickIntent != null) { - // Defensive compatibility for an exported route that was wrapped - // by an older notification already present in the shade. - startTargetActivity(targetIntent, clickIntent, container, false); + result = TargetSdkClickDispatcher.dispatchReplay(this, pendingContainer); + } else if (pendingContainer != null && pendingPayload != null) { + result = TargetSdkClickDispatcher.dispatchPayload( + this, pendingContainer, pendingPayload); } } catch (Throwable error) { - Log.w(TAG, "notification click hand-off failed", error); - try { - if (!targetTaskPrimed) { - if (pendingManualReplay) { - startTargetLauncher(pendingTargetPackage); - } else if (clickIntent != null) { - startTargetActivity( - targetIntent, clickIntent, container, - pendingTargetActivityPrivate); - } - } - } catch (Throwable fallbackError) { - Log.w(TAG, "notification click Activity fallback failed", fallbackError); + 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 { - cancelClickedNotification(); - finishClickTask(); + finishAfterClick("DIRECT_TARGET"); } } - private void completeClickWithoutConfirmedTarget(String reason) { - if (dispatched) { + private void startFallbackAndFinish(String reason) { + if (clickFinished) { return; } - Log.w(TAG, reason + ": delivering payload with isolated-task fallback"); - completeClick(); + 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 abandonAfterTargetLaunch(String reason) { - if (dispatched) { + private void finishAfterClick(String reason) { + if (clickFinished) { return; } - dispatched = true; - dispatchAfterTargetVisible = false; + clickFinished = true; + waitingForTargetNavigation = false; mainHandler.removeCallbacks(targetVisibilityProbe); - Log.w(TAG, reason + ": retaining target launcher fallback without SDK delivery"); + 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; @@ -322,10 +332,25 @@ private boolean isTargetTaskVisible() { 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); - return importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND - || importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_VISIBLE; + // 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. @@ -367,14 +392,12 @@ private String resolveTargetPackage( } private void finishClickTask() { - if (isTaskRoot()) { - finishAndRemoveTask(); - } else { - finish(); - } + // 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 void startTargetActivity( + private boolean startTargetActivity( @Nullable Intent targetIntent, Intent clickIntent, @Nullable XmPushActionContainer container, @@ -390,7 +413,7 @@ private void startTargetActivity( } } if (launch == null) { - return; + return false; } if (targetActivityPrivate) { @@ -412,6 +435,7 @@ private void startTargetActivity( 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); diff --git a/push/src/test/java/com/xiaomi/push/sdk/TargetSdkClickDispatcherTest.java b/push/src/test/java/com/xiaomi/push/sdk/TargetSdkClickDispatcherTest.java index 56fadc280..9f67c4803 100644 --- a/push/src/test/java/com/xiaomi/push/sdk/TargetSdkClickDispatcherTest.java +++ b/push/src/test/java/com/xiaomi/push/sdk/TargetSdkClickDispatcherTest.java @@ -80,11 +80,45 @@ public void deliveryAcceptanceDoesNotUseNavigationTerminology() { } @Test - public void privateAndReplayRoutesPrimeTargetTask() { - assertTrue(TargetSdkClickDispatcher.shouldPrimeTargetTask(false, true)); - assertTrue(TargetSdkClickDispatcher.shouldPrimeTargetTask(true, false)); - assertTrue(TargetSdkClickDispatcher.shouldPrimeTargetTask(true, true)); - assertFalse(TargetSdkClickDispatcher.shouldPrimeTargetTask(false, false)); + 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