Browse Source

完善原生推送多语言语音及系统通知声音

qmj 19 hours ago
parent
commit
c5c7fd0f4d
87 changed files with 1056 additions and 14 deletions
  1. 23 5
      ruoyi-admin/src/main/java/com/ruoyi/app/push/ApnsPushSender.java
  2. 10 2
      ruoyi-admin/src/main/java/com/ruoyi/app/push/BusinessPushService.java
  3. 24 2
      ruoyi-admin/src/main/java/com/ruoyi/app/push/FcmPushSender.java
  4. 12 0
      ruoyi-admin/src/main/java/com/ruoyi/app/push/NativePushSound.java
  5. 94 0
      ruoyi-admin/src/main/java/com/ruoyi/app/push/NativePushSoundResolver.java
  6. 11 2
      ruoyi-admin/src/main/java/com/ruoyi/app/push/NativePushTestController.java
  7. 10 0
      ruoyi-admin/src/main/java/com/ruoyi/app/push/NativePushTestRequest.java
  8. 2 1
      ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryNotificationServiceTest.java
  9. 26 0
      ruoyi-admin/src/test/java/com/ruoyi/app/push/ApnsPushyTransportTest.java
  10. 1 1
      ruoyi-admin/src/test/java/com/ruoyi/app/push/BusinessPushServiceTest.java
  11. 12 0
      ruoyi-admin/src/test/java/com/ruoyi/app/push/FcmPushSenderTest.java
  12. 145 0
      ruoyi-admin/src/test/java/com/ruoyi/app/push/NativePushSoundTest.java
  13. 2 1
      ruoyi-admin/src/test/java/com/ruoyi/app/push/NativePushTestControllerTest.java
  14. 4 0
      specs/033-native-push-routing/plan.md
  15. 2 0
      specs/033-native-push-routing/quickstart.md
  16. 4 0
      specs/033-native-push-routing/spec.md
  17. 10 0
      specs/033-native-push-routing/tasks.md
  18. 7 0
      specs/035-user-notification-voice/assets/native-wav/README.md
  19. BIN
      specs/035-user-notification-voice/assets/native-wav/flash_delivery_stop_completed_en.wav
  20. BIN
      specs/035-user-notification-voice/assets/native-wav/flash_delivery_stop_completed_id.wav
  21. BIN
      specs/035-user-notification-voice/assets/native-wav/flash_delivery_stop_completed_ms.wav
  22. BIN
      specs/035-user-notification-voice/assets/native-wav/flash_delivery_stop_completed_th.wav
  23. BIN
      specs/035-user-notification-voice/assets/native-wav/flash_delivery_stop_completed_tl.wav
  24. BIN
      specs/035-user-notification-voice/assets/native-wav/flash_delivery_stop_completed_zh_hans.wav
  25. BIN
      specs/035-user-notification-voice/assets/native-wav/flash_delivery_stop_completed_zh_hant.wav
  26. BIN
      specs/035-user-notification-voice/assets/native-wav/flash_pickup_stop_completed_en.wav
  27. BIN
      specs/035-user-notification-voice/assets/native-wav/flash_pickup_stop_completed_id.wav
  28. BIN
      specs/035-user-notification-voice/assets/native-wav/flash_pickup_stop_completed_ms.wav
  29. BIN
      specs/035-user-notification-voice/assets/native-wav/flash_pickup_stop_completed_th.wav
  30. BIN
      specs/035-user-notification-voice/assets/native-wav/flash_pickup_stop_completed_tl.wav
  31. BIN
      specs/035-user-notification-voice/assets/native-wav/flash_pickup_stop_completed_zh_hans.wav
  32. BIN
      specs/035-user-notification-voice/assets/native-wav/flash_pickup_stop_completed_zh_hant.wav
  33. 513 0
      specs/035-user-notification-voice/assets/native-wav/manifest.json
  34. BIN
      specs/035-user-notification-voice/assets/native-wav/merchant_accepted_en.wav
  35. BIN
      specs/035-user-notification-voice/assets/native-wav/merchant_accepted_id.wav
  36. BIN
      specs/035-user-notification-voice/assets/native-wav/merchant_accepted_ms.wav
  37. BIN
      specs/035-user-notification-voice/assets/native-wav/merchant_accepted_th.wav
  38. BIN
      specs/035-user-notification-voice/assets/native-wav/merchant_accepted_tl.wav
  39. BIN
      specs/035-user-notification-voice/assets/native-wav/merchant_accepted_zh_hans.wav
  40. BIN
      specs/035-user-notification-voice/assets/native-wav/merchant_accepted_zh_hant.wav
  41. BIN
      specs/035-user-notification-voice/assets/native-wav/merchant_ready_en.wav
  42. BIN
      specs/035-user-notification-voice/assets/native-wav/merchant_ready_id.wav
  43. BIN
      specs/035-user-notification-voice/assets/native-wav/merchant_ready_ms.wav
  44. BIN
      specs/035-user-notification-voice/assets/native-wav/merchant_ready_th.wav
  45. BIN
      specs/035-user-notification-voice/assets/native-wav/merchant_ready_tl.wav
  46. BIN
      specs/035-user-notification-voice/assets/native-wav/merchant_ready_zh_hans.wav
  47. BIN
      specs/035-user-notification-voice/assets/native-wav/merchant_ready_zh_hant.wav
  48. BIN
      specs/035-user-notification-voice/assets/native-wav/order_cancelled_en.wav
  49. BIN
      specs/035-user-notification-voice/assets/native-wav/order_cancelled_id.wav
  50. BIN
      specs/035-user-notification-voice/assets/native-wav/order_cancelled_ms.wav
  51. BIN
      specs/035-user-notification-voice/assets/native-wav/order_cancelled_th.wav
  52. BIN
      specs/035-user-notification-voice/assets/native-wav/order_cancelled_tl.wav
  53. BIN
      specs/035-user-notification-voice/assets/native-wav/order_cancelled_zh_hans.wav
  54. BIN
      specs/035-user-notification-voice/assets/native-wav/order_cancelled_zh_hant.wav
  55. BIN
      specs/035-user-notification-voice/assets/native-wav/order_delivered_en.wav
  56. BIN
      specs/035-user-notification-voice/assets/native-wav/order_delivered_id.wav
  57. BIN
      specs/035-user-notification-voice/assets/native-wav/order_delivered_ms.wav
  58. BIN
      specs/035-user-notification-voice/assets/native-wav/order_delivered_th.wav
  59. BIN
      specs/035-user-notification-voice/assets/native-wav/order_delivered_tl.wav
  60. BIN
      specs/035-user-notification-voice/assets/native-wav/order_delivered_zh_hans.wav
  61. BIN
      specs/035-user-notification-voice/assets/native-wav/order_delivered_zh_hant.wav
  62. BIN
      specs/035-user-notification-voice/assets/native-wav/order_delivering_en.wav
  63. BIN
      specs/035-user-notification-voice/assets/native-wav/order_delivering_id.wav
  64. BIN
      specs/035-user-notification-voice/assets/native-wav/order_delivering_ms.wav
  65. BIN
      specs/035-user-notification-voice/assets/native-wav/order_delivering_th.wav
  66. BIN
      specs/035-user-notification-voice/assets/native-wav/order_delivering_tl.wav
  67. BIN
      specs/035-user-notification-voice/assets/native-wav/order_delivering_zh_hans.wav
  68. BIN
      specs/035-user-notification-voice/assets/native-wav/order_delivering_zh_hant.wav
  69. BIN
      specs/035-user-notification-voice/assets/native-wav/payment_succeeded_en.wav
  70. BIN
      specs/035-user-notification-voice/assets/native-wav/payment_succeeded_id.wav
  71. BIN
      specs/035-user-notification-voice/assets/native-wav/payment_succeeded_ms.wav
  72. BIN
      specs/035-user-notification-voice/assets/native-wav/payment_succeeded_th.wav
  73. BIN
      specs/035-user-notification-voice/assets/native-wav/payment_succeeded_tl.wav
  74. BIN
      specs/035-user-notification-voice/assets/native-wav/payment_succeeded_zh_hans.wav
  75. BIN
      specs/035-user-notification-voice/assets/native-wav/payment_succeeded_zh_hant.wav
  76. BIN
      specs/035-user-notification-voice/assets/native-wav/rider_accepted_en.wav
  77. BIN
      specs/035-user-notification-voice/assets/native-wav/rider_accepted_id.wav
  78. BIN
      specs/035-user-notification-voice/assets/native-wav/rider_accepted_ms.wav
  79. BIN
      specs/035-user-notification-voice/assets/native-wav/rider_accepted_th.wav
  80. BIN
      specs/035-user-notification-voice/assets/native-wav/rider_accepted_tl.wav
  81. BIN
      specs/035-user-notification-voice/assets/native-wav/rider_accepted_zh_hans.wav
  82. BIN
      specs/035-user-notification-voice/assets/native-wav/rider_accepted_zh_hant.wav
  83. 2 0
      specs/035-user-notification-voice/frontend-integration.md
  84. 105 0
      specs/035-user-notification-voice/native-sound-integration.md
  85. 10 0
      specs/035-user-notification-voice/plan.md
  86. 14 0
      specs/035-user-notification-voice/spec.md
  87. 13 0
      specs/035-user-notification-voice/tasks.md

+ 23 - 5
ruoyi-admin/src/main/java/com/ruoyi/app/push/ApnsPushSender.java

@@ -136,15 +136,25 @@ public class ApnsPushSender {
     }
     }
 
 
     public void send(RecipientApp app, String token, String title, String content, String payload) {
     public void send(RecipientApp app, String token, String title, String content, String payload) {
-        sendToClient(app, token, title, content, payload, properties.getApnsEnvironment());
+        send(app, token, title, content, payload, null);
+    }
+
+    public void send(RecipientApp app, String token, String title, String content, String payload,
+                     NativePushSound sound) {
+        sendToClient(app, token, title, content, payload, properties.getApnsEnvironment(), sound);
     }
     }
 
 
     /** 原生测试也遵循所配 APNs 环境;只按需加载目标端,不切换业务推送通道。 */
     /** 原生测试也遵循所配 APNs 环境;只按需加载目标端,不切换业务推送通道。 */
     public void sendForTest(RecipientApp app, String token, String title,
     public void sendForTest(RecipientApp app, String token, String title,
                             String content, String payload) {
                             String content, String payload) {
+        sendForTest(app, token, title, content, payload, null);
+    }
+
+    public void sendForTest(RecipientApp app, String token, String title,
+                            String content, String payload, NativePushSound sound) {
         BusinessPushProperties.ApnsEnvironment environment = properties.getApnsEnvironment();
         BusinessPushProperties.ApnsEnvironment environment = properties.getApnsEnvironment();
         initializeClient(app, environment);
         initializeClient(app, environment);
-        sendToClient(app, token, title, content, payload, environment);
+        sendToClient(app, token, title, content, payload, environment, sound);
     }
     }
 
 
     private synchronized void initializeClient(RecipientApp app, BusinessPushProperties.ApnsEnvironment environment) {
     private synchronized void initializeClient(RecipientApp app, BusinessPushProperties.ApnsEnvironment environment) {
@@ -173,7 +183,8 @@ public class ApnsPushSender {
     }
     }
 
 
     private void sendToClient(RecipientApp app, String token, String title, String content,
     private void sendToClient(RecipientApp app, String token, String title, String content,
-                              String payload, BusinessPushProperties.ApnsEnvironment environment) {
+                              String payload, BusinessPushProperties.ApnsEnvironment environment,
+                              NativePushSound sound) {
         ApnsClient client = clients.get(new ClientKey(app, environment));
         ApnsClient client = clients.get(new ClientKey(app, environment));
         if (client == null) {
         if (client == null) {
             throw new IllegalStateException("APNS_NOT_INITIALIZED");
             throw new IllegalStateException("APNS_NOT_INITIALIZED");
@@ -181,7 +192,7 @@ public class ApnsPushSender {
         if (token == null || !token.matches("[0-9a-fA-F]{64}")) {
         if (token == null || !token.matches("[0-9a-fA-F]{64}")) {
             throw new IllegalStateException("APNS_TOKEN_INVALID");
             throw new IllegalStateException("APNS_TOKEN_INVALID");
         }
         }
-        String body = buildBody(app, title, content, payload);
+        String body = buildBody(app, title, content, payload, sound);
         if (body.getBytes(StandardCharsets.UTF_8).length > 4096) {
         if (body.getBytes(StandardCharsets.UTF_8).length > 4096) {
             throw new IllegalStateException("APNS_PAYLOAD_TOO_LARGE");
             throw new IllegalStateException("APNS_PAYLOAD_TOO_LARGE");
         }
         }
@@ -208,14 +219,21 @@ public class ApnsPushSender {
     }
     }
 
 
     static String buildBody(RecipientApp app, String title, String content, String payload) {
     static String buildBody(RecipientApp app, String title, String content, String payload) {
+        return buildBody(app, title, content, payload, null);
+    }
+
+    static String buildBody(RecipientApp app, String title, String content, String payload, NativePushSound sound) {
         JSONObject alert = new JSONObject();
         JSONObject alert = new JSONObject();
         alert.put("title", title == null ? "" : title);
         alert.put("title", title == null ? "" : title);
         alert.put("body", content == null ? "" : content);
         alert.put("body", content == null ? "" : content);
         JSONObject aps = new JSONObject();
         JSONObject aps = new JSONObject();
         aps.put("alert", alert);
         aps.put("alert", alert);
-        aps.put("sound", app == RecipientApp.RIDER ? "pushsound.caf" : "default");
+        aps.put("sound", sound != null ? sound.fileName() : app == RecipientApp.RIDER ? "pushsound.caf" : "default");
         JSONObject body = new JSONObject();
         JSONObject body = new JSONObject();
         body.put("aps", aps);
         body.put("aps", aps);
+        if (sound != null) {
+            body.put("sound", sound.fileName());
+        }
         if (payload != null) {
         if (payload != null) {
             body.put("payload", payload);
             body.put("payload", payload);
             try {
             try {

+ 10 - 2
ruoyi-admin/src/main/java/com/ruoyi/app/push/BusinessPushService.java

@@ -18,14 +18,17 @@ public class BusinessPushService {
     private final FcmPushSender fcm;
     private final FcmPushSender fcm;
     private final ApnsPushSender apns;
     private final ApnsPushSender apns;
     private final PayPush uni;
     private final PayPush uni;
+    private final NativePushSoundResolver sounds;
 
 
     public BusinessPushService(BusinessPushProperties properties, IInfoUserService users,
     public BusinessPushService(BusinessPushProperties properties, IInfoUserService users,
-                               FcmPushSender fcm, ApnsPushSender apns, PayPush uni) {
+                               FcmPushSender fcm, ApnsPushSender apns, PayPush uni,
+                               NativePushSoundResolver sounds) {
         this.properties = properties;
         this.properties = properties;
         this.users = users;
         this.users = users;
         this.fcm = fcm;
         this.fcm = fcm;
         this.apns = apns;
         this.apns = apns;
         this.uni = uni;
         this.uni = uni;
+        this.sounds = sounds;
     }
     }
 
 
     public void send(RecipientApp app, Long userId, String cid,
     public void send(RecipientApp app, Long userId, String cid,
@@ -53,7 +56,12 @@ public class BusinessPushService {
                         app, userId, platform);
                         app, userId, platform);
                 return;
                 return;
             }
             }
-            if ("android".equals(platform)) {
+            NativePushSound sound = sounds.forRecipient(app, userId, payload);
+            if (sound != null && "android".equals(platform)) {
+                fcm.send(app, current.getDeviceToken(), title, content, payload, sound);
+            } else if (sound != null) {
+                apns.send(app, current.getDeviceToken(), title, content, payload, sound);
+            } else if ("android".equals(platform)) {
                 fcm.send(app, current.getDeviceToken(), title, content, payload);
                 fcm.send(app, current.getDeviceToken(), title, content, payload);
             } else {
             } else {
                 apns.send(app, current.getDeviceToken(), title, content, payload);
                 apns.send(app, current.getDeviceToken(), title, content, payload);

+ 24 - 2
ruoyi-admin/src/main/java/com/ruoyi/app/push/FcmPushSender.java

@@ -87,6 +87,11 @@ public class FcmPushSender {
     }
     }
 
 
     public void send(RecipientApp app, String token, String title, String content, String payload) {
     public void send(RecipientApp app, String token, String title, String content, String payload) {
+        send(app, token, title, content, payload, null);
+    }
+
+    public void send(RecipientApp app, String token, String title, String content, String payload,
+                     NativePushSound sound) {
         FirebaseMessaging client = messaging.get(app);
         FirebaseMessaging client = messaging.get(app);
         if (client == null) {
         if (client == null) {
             throw new IllegalStateException("FCM_NOT_INITIALIZED");
             throw new IllegalStateException("FCM_NOT_INITIALIZED");
@@ -95,7 +100,7 @@ public class FcmPushSender {
             throw new IllegalStateException("FCM_TOKEN_MISSING");
             throw new IllegalStateException("FCM_TOKEN_MISSING");
         }
         }
         try {
         try {
-            client.send(buildMessage(app, token, title, content, payload));
+            client.send(buildMessage(app, token, title, content, payload, sound));
         } catch (FirebaseMessagingException exception) {
         } catch (FirebaseMessagingException exception) {
             MessagingErrorCode code = exception.getMessagingErrorCode();
             MessagingErrorCode code = exception.getMessagingErrorCode();
             String category = code == null ? "UNKNOWN" : code.name();
             String category = code == null ? "UNKNOWN" : code.name();
@@ -106,8 +111,13 @@ public class FcmPushSender {
 
 
     /** Test delivery initializes only the selected app, without changing the business push mode. */
     /** Test delivery initializes only the selected app, without changing the business push mode. */
     public void sendForTest(RecipientApp app, String token, String title, String content, String payload) {
     public void sendForTest(RecipientApp app, String token, String title, String content, String payload) {
+        sendForTest(app, token, title, content, payload, null);
+    }
+
+    public void sendForTest(RecipientApp app, String token, String title, String content, String payload,
+                            NativePushSound sound) {
         initializeForTest(app);
         initializeForTest(app);
-        send(app, token, title, content, payload);
+        send(app, token, title, content, payload, sound);
     }
     }
 
 
     private synchronized void initializeForTest(RecipientApp app) {
     private synchronized void initializeForTest(RecipientApp app) {
@@ -184,12 +194,21 @@ public class FcmPushSender {
     }
     }
 
 
     static Message buildMessage(RecipientApp app, String token, String title, String content, String payload) {
     static Message buildMessage(RecipientApp app, String token, String title, String content, String payload) {
+        return buildMessage(app, token, title, content, payload, null);
+    }
+
+    static Message buildMessage(RecipientApp app, String token, String title, String content, String payload,
+                                NativePushSound sound) {
         String safeTitle = title == null ? "" : title;
         String safeTitle = title == null ? "" : title;
         String safeContent = content == null ? "" : content;
         String safeContent = content == null ? "" : content;
         AndroidNotification.Builder androidNotification = AndroidNotification.builder().setDefaultSound(true);
         AndroidNotification.Builder androidNotification = AndroidNotification.builder().setDefaultSound(true);
         if (app == RecipientApp.RIDER) {
         if (app == RecipientApp.RIDER) {
             androidNotification.setChannelId("qsRing");
             androidNotification.setChannelId("qsRing");
         }
         }
+        if (sound != null) {
+            // Android 8+ 最终使用 App 预建渠道的声音,sound 同时兼容旧系统。
+            androidNotification.setDefaultSound(false).setSound(sound.resourceName()).setChannelId(sound.channelId());
+        }
         Message.Builder message = Message.builder()
         Message.Builder message = Message.builder()
                 .setToken(token)
                 .setToken(token)
                 .setNotification(Notification.builder().setTitle(safeTitle).setBody(safeContent).build())
                 .setNotification(Notification.builder().setTitle(safeTitle).setBody(safeContent).build())
@@ -197,6 +216,9 @@ public class FcmPushSender {
                         .setNotification(androidNotification.build()).build())
                         .setNotification(androidNotification.build()).build())
                 .putData("title", safeTitle)
                 .putData("title", safeTitle)
                 .putData("body", safeContent);
                 .putData("body", safeContent);
+        if (sound != null) {
+            message.putData("sound", sound.fileName()).putData("channelId", sound.channelId());
+        }
         if (payload != null) {
         if (payload != null) {
             message.putData("payload", payload);
             message.putData("payload", payload);
             try {
             try {

+ 12 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/push/NativePushSound.java

@@ -0,0 +1,12 @@
+package com.ruoyi.app.push;
+
+/** 两端共用 WAV 文件名;Android 原始资源名不带扩展名,渠道固定绑定该声音。 */
+public record NativePushSound(String resourceName) {
+    public String fileName() {
+        return resourceName + ".wav";
+    }
+
+    public String channelId() {
+        return "user_voice_" + resourceName + "_v1";
+    }
+}

+ 94 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/push/NativePushSoundResolver.java

@@ -0,0 +1,94 @@
+package com.ruoyi.app.push;
+
+import com.alibaba.fastjson2.JSON;
+import com.alibaba.fastjson2.JSONObject;
+import com.ruoyi.system.utils.JedisFactory;
+import org.springframework.stereotype.Component;
+import redis.clients.jedis.Jedis;
+
+import java.util.Locale;
+import java.util.Set;
+
+/** 仅处理原生用户通知;从明确场景选择随包音频,不根据状态或通知文案猜测声音。 */
+@Component
+public class NativePushSoundResolver {
+    private static final Set<String> SCENES = Set.of("RIDER_ACCEPTED", "ORDER_DELIVERING", "ORDER_DELIVERED",
+            "MERCHANT_ACCEPTED", "MERCHANT_READY", "PAYMENT_SUCCEEDED", "ORDER_CANCELLED");
+    private static final Set<String> STOP_EVENTS = Set.of("FLASH_PICKUP_STOP_COMPLETED", "FLASH_DELIVERY_STOP_COMPLETED");
+    private final JedisFactory jedisFactory;
+
+    public NativePushSoundResolver(JedisFactory jedisFactory) {
+        this.jedisFactory = jedisFactory;
+    }
+
+    public NativePushSound forRecipient(RecipientApp app, Long userId, String payload) {
+        String scene = scene(app, payload);
+        if (scene == null) {
+            return null;
+        }
+        String language = null;
+        if (userId != null) {
+            // 与 /changeLanguages 保持同一 DB 和原始字符串格式,不经过 JSON Redis 反序列化。
+            try (Jedis jedis = jedisFactory.create()) {
+                language = jedis.get(userId.toString());
+            } catch (RuntimeException ignored) {
+                // 缺失或暂时读不到语言不阻断通知,统一回退繁体中文。
+            }
+        }
+        return sound(scene, language);
+    }
+
+    public NativePushSound forTest(RecipientApp app, String language, String payload) {
+        String scene = scene(app, payload);
+        return scene == null ? null : sound(scene, language);
+    }
+
+    private static String scene(RecipientApp app, String payload) {
+        if (app != RecipientApp.USER || payload == null) {
+            return null;
+        }
+        try {
+            JSONObject fields = JSON.parseObject(payload);
+            if (fields == null) {
+                return null;
+            }
+            String scene = fields.getString("eventScene");
+            if (scene != null && SCENES.contains(scene)) {
+                return scene;
+            }
+            String event = fields.getString("eventType");
+            return event != null && STOP_EVENTS.contains(event) ? event : null;
+        } catch (RuntimeException ignored) {
+            return null;
+        }
+    }
+
+    private static NativePushSound sound(String scene, String language) {
+        return new NativePushSound(scene.toLowerCase(Locale.ROOT) + "_" + languageSuffix(language));
+    }
+
+    private static String languageSuffix(String language) {
+        if (language == null || language.isBlank()) {
+            return "zh_hant";
+        }
+        String tag = language.trim().replace('_', '-').toLowerCase(Locale.ROOT);
+        if (tag.equals("zhhant") || tag.equals("tw") || tag.equals("zhtw")
+                || tag.equals("zh-tw") || tag.equals("zh-hk") || tag.equals("zh-mo")
+                || tag.equals("zh-hant") || tag.startsWith("zh-hant-")) {
+            return "zh_hant";
+        }
+        if (tag.equals("zhhans") || tag.equals("cn") || tag.equals("zhcn")
+                || tag.equals("zh-cn") || tag.equals("zh-sg")
+                || tag.equals("zh-hans") || tag.startsWith("zh-hans-")) {
+            return "zh_hans";
+        }
+        return switch (tag.split("-", 2)[0]) {
+            case "en" -> "en";
+            case "th" -> "th";
+            case "id", "in" -> "id";
+            case "ms" -> "ms";
+            case "tl", "fil" -> "tl";
+            default -> "zh_hant";
+        };
+    }
+}

+ 11 - 2
ruoyi-admin/src/main/java/com/ruoyi/app/push/NativePushTestController.java

@@ -19,10 +19,12 @@ public class NativePushTestController {
 
 
     private final FcmPushSender fcm;
     private final FcmPushSender fcm;
     private final ApnsPushSender apns;
     private final ApnsPushSender apns;
+    private final NativePushSoundResolver sounds;
 
 
-    public NativePushTestController(FcmPushSender fcm, ApnsPushSender apns) {
+    public NativePushTestController(FcmPushSender fcm, ApnsPushSender apns, NativePushSoundResolver sounds) {
         this.fcm = fcm;
         this.fcm = fcm;
         this.apns = apns;
         this.apns = apns;
+        this.sounds = sounds;
     }
     }
 
 
     /** Explicit test path: does not read an account or change the ordinary business push mode. */
     /** Explicit test path: does not read an account or change the ordinary business push mode. */
@@ -45,7 +47,14 @@ public class NativePushTestController {
             return AjaxResult.error(MessageUtils.message("native.push.test.invalid"));
             return AjaxResult.error(MessageUtils.message("native.push.test.invalid"));
         }
         }
         try {
         try {
-            if ("android".equals(platform)) {
+            NativePushSound sound = sounds.forTest(app, request.getLanguage(), request.getPayload());
+            if (sound != null && "android".equals(platform)) {
+                fcm.sendForTest(app, request.getDeviceToken(), request.getTitle(),
+                        request.getContent(), request.getPayload(), sound);
+            } else if (sound != null) {
+                apns.sendForTest(app, request.getDeviceToken(), request.getTitle(),
+                        request.getContent(), request.getPayload(), sound);
+            } else if ("android".equals(platform)) {
                 fcm.sendForTest(app, request.getDeviceToken(), request.getTitle(),
                 fcm.sendForTest(app, request.getDeviceToken(), request.getTitle(),
                         request.getContent(), request.getPayload());
                         request.getContent(), request.getPayload());
             } else {
             } else {

+ 10 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/push/NativePushTestRequest.java

@@ -8,6 +8,16 @@ public class NativePushTestRequest {
     private String title;
     private String title;
     private String content;
     private String content;
     private String payload;
     private String payload;
+    /** 可选,原生用户语音测试使用;缺省或无法识别时使用繁体中文。 */
+    private String language;
+
+    public String getLanguage() {
+        return language;
+    }
+
+    public void setLanguage(String language) {
+        this.language = language;
+    }
 
 
     public String getAppType() {
     public String getAppType() {
         return appType;
         return appType;

+ 2 - 1
ruoyi-admin/src/test/java/com/ruoyi/app/flashdelivery/service/FlashDeliveryNotificationServiceTest.java

@@ -498,7 +498,8 @@ class FlashDeliveryNotificationServiceTest {
             IInfoUserService currentUsers = mock(IInfoUserService.class);
             IInfoUserService currentUsers = mock(IInfoUserService.class);
             when(currentUsers.getById(7L)).thenReturn(sender);
             when(currentUsers.getById(7L)).thenReturn(sender);
             BusinessPushService router = new BusinessPushService(properties, currentUsers,
             BusinessPushService router = new BusinessPushService(properties, currentUsers,
-                    mock(FcmPushSender.class), mock(ApnsPushSender.class), uni);
+                    mock(FcmPushSender.class), mock(ApnsPushSender.class), uni,
+                    mock(com.ruoyi.app.push.NativePushSoundResolver.class));
             IPushMessageService repository = mock(IPushMessageService.class);
             IPushMessageService repository = mock(IPushMessageService.class);
             doAnswer(call -> {
             doAnswer(call -> {
                 if (persistenceFailure) throw new IllegalStateException("message storage unavailable");
                 if (persistenceFailure) throw new IllegalStateException("message storage unavailable");

+ 26 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/push/ApnsPushyTransportTest.java

@@ -17,6 +17,32 @@ import static org.mockito.Mockito.*;
 
 
 /** 发送Apple类型化通知并以Apple接受结果为准,不把队列成功当投递接受。 */
 /** 发送Apple类型化通知并以Apple接受结果为准,不把队列成功当投递接受。 */
 class ApnsPushyTransportTest {
 class ApnsPushyTransportTest {
+    @Test
+    void rawTokenTestForwardsCustomSoundToApple() throws Exception {
+        var properties = new BusinessPushProperties();
+        var config = new BusinessPushProperties.AppProperties();
+        config.setApnsCertificatePath("user.p12");
+        config.setApnsCertificatePassword("fixture-password");
+        config.setApnsTopic("com.example.user");
+        properties.getApps().put(RecipientApp.USER, config);
+        ApnsClient client = acceptedClient();
+        ApnsPushSender sender = new ApnsPushSender(properties) {
+            @Override
+            protected ApnsClient createClient(RecipientApp app, BusinessPushProperties.ApnsEnvironment environment,
+                                               BusinessPushProperties.AppProperties actualConfig) {
+                return client;
+            }
+        };
+        NativePushSound sound = new NativePushSound("rider_accepted_zh_hant");
+        sender.sendForTest(RecipientApp.USER, "a".repeat(64), "title", "body", "{}", sound);
+        var capture = org.mockito.ArgumentCaptor.forClass(SimpleApnsPushNotification.class);
+        verify(client).sendNotification(capture.capture());
+        var payload = JSON.parseObject(capture.getValue().getPayload());
+        assertEquals(sound.fileName(), payload.getJSONObject("aps").getString("sound"));
+        assertEquals(sound.fileName(), payload.getString("sound"));
+        sender.close();
+    }
+
     @Test
     @Test
     void configuredTestEnvironmentNeverReusesOtherGatewayAndClientsAreReusedAndClosed() throws Exception {
     void configuredTestEnvironmentNeverReusesOtherGatewayAndClientsAreReusedAndClosed() throws Exception {
         var properties = new BusinessPushProperties();
         var properties = new BusinessPushProperties();

+ 1 - 1
ruoyi-admin/src/test/java/com/ruoyi/app/push/BusinessPushServiceTest.java

@@ -189,7 +189,7 @@ class BusinessPushServiceTest {
     }
     }
 
 
     private BusinessPushService service() {
     private BusinessPushService service() {
-        return new BusinessPushService(properties, users, fcm, apns, uni);
+        return new BusinessPushService(properties, users, fcm, apns, uni, mock(NativePushSoundResolver.class));
     }
     }
 
 
     private InfoUser user(Long id, String platform, String token) {
     private InfoUser user(Long id, String platform, String token) {

+ 12 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/push/FcmPushSenderTest.java

@@ -31,6 +31,18 @@ class FcmPushSenderTest {
     @TempDir
     @TempDir
     Path tempDir;
     Path tempDir;
 
 
+    @Test
+    void rawTokenTestForwardsCustomSoundToFirebase() throws Exception {
+        StubSender sender = new StubSender(nativeProperties());
+        NativePushSound sound = new NativePushSound("rider_accepted_zh_hant");
+        sender.sendForTest(RecipientApp.USER, "token", "title", "body", "{}", sound);
+        var capture = org.mockito.ArgumentCaptor.forClass(Message.class);
+        verify(sender.messaging.get(RecipientApp.USER)).send(capture.capture());
+        Object notification = field(field(capture.getValue(), "androidConfig"), "notification");
+        assertEquals(sound.resourceName(), field(notification, "sound"));
+        assertEquals(sound.channelId(), field(notification, "channelId"));
+    }
+
     @org.junit.jupiter.params.ParameterizedTest
     @org.junit.jupiter.params.ParameterizedTest
     @org.junit.jupiter.params.provider.EnumSource(UserNotificationScene.class)
     @org.junit.jupiter.params.provider.EnumSource(UserNotificationScene.class)
     void userSceneStaysInFullPayloadAndKeepsDefaultSound(UserNotificationScene scene) throws Exception {
     void userSceneStaysInFullPayloadAndKeepsDefaultSound(UserNotificationScene scene) throws Exception {

+ 145 - 0
ruoyi-admin/src/test/java/com/ruoyi/app/push/NativePushSoundTest.java

@@ -0,0 +1,145 @@
+package com.ruoyi.app.push;
+
+import com.alibaba.fastjson2.JSON;
+import com.google.firebase.messaging.Message;
+import com.ruoyi.app.utils.PayPush;
+import com.ruoyi.system.utils.JedisFactory;
+import redis.clients.jedis.Jedis;
+import com.ruoyi.common.utils.MessageUtils;
+import com.ruoyi.system.domain.InfoUser;
+import com.ruoyi.system.service.IInfoUserService;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.lang.reflect.Field;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+class NativePushSoundTest {
+    private final JedisFactory cache = mock(JedisFactory.class);
+    private final Jedis jedis = mock(Jedis.class);
+    private final NativePushSoundResolver sounds = new NativePushSoundResolver(cache);
+
+    @ParameterizedTest
+    @CsvSource({"zhHant,zh_hant", "zh-Hant,zh_hant", "zh_TW,zh_hant", "tw,zh_hant",
+            "zh-Hant-TW,zh_hant", "zhHans,zh_hans", "zh-CN,zh_hans", "zh-Hans-CN,zh_hans",
+            "en-US,en", "th,th", "id-ID,id", "in,id", "ms-MY,ms", "fil-PH,tl", "tl,tl",
+            "unknown,zh_hant", "vi,zh_hant", "zh,zh_hant"})
+    void selectsCanonicalLanguage(String language, String suffix) {
+        NativePushSound sound = sounds.forTest(RecipientApp.USER, language, scene("RIDER_ACCEPTED"));
+        assertEquals("rider_accepted_" + suffix + ".wav", sound.fileName());
+        assertEquals("user_voice_rider_accepted_" + suffix + "_v1", sound.channelId());
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"RIDER_ACCEPTED", "ORDER_DELIVERING", "ORDER_DELIVERED", "MERCHANT_ACCEPTED",
+            "MERCHANT_READY", "PAYMENT_SUCCEEDED", "ORDER_CANCELLED",
+            "FLASH_PICKUP_STOP_COMPLETED", "FLASH_DELIVERY_STOP_COMPLETED"})
+    void recognizesOnlyExplicitScenes(String scene) {
+        String key = scene.startsWith("FLASH_") ? "eventType" : "eventScene";
+        NativePushSound sound = sounds.forTest(RecipientApp.USER, null, "{\"" + key + "\":\"" + scene + "\"}");
+        assertEquals(scene.toLowerCase(java.util.Locale.ROOT) + "_zh_hant.wav", sound.fileName());
+    }
+
+    @Test
+    void oldOrMalformedPayloadAndOtherRecipientsKeepOriginalSound() {
+        for (String payload : new String[]{null, "oops", "null", "{}", "{\"state\":2,\"type\":0}", scene("UNKNOWN")}) {
+            assertNull(sounds.forTest(RecipientApp.USER, "en", payload));
+        }
+        assertNull(sounds.forRecipient(RecipientApp.RIDER, 9L, scene("RIDER_ACCEPTED")));
+        assertNull(sounds.forRecipient(RecipientApp.MERCHANT, 9L, scene("MERCHANT_READY")));
+        verifyNoInteractions(cache);
+    }
+
+    @Test
+    void readsRawRecipientPreferenceAndFallsBackOnMissingOrFailedCache() {
+        when(cache.create()).thenReturn(jedis);
+        when(jedis.get("9")).thenReturn("id");
+        assertEquals("rider_accepted_id.wav", sounds.forRecipient(RecipientApp.USER, 9L, scene("RIDER_ACCEPTED")).fileName());
+        assertEquals("rider_accepted_zh_hant.wav", sounds.forRecipient(RecipientApp.USER, null, scene("RIDER_ACCEPTED")).fileName());
+        verify(jedis).close();
+        when(jedis.get("9")).thenThrow(new IllegalStateException("offline"));
+        assertEquals("rider_accepted_zh_hant.wav", sounds.forRecipient(RecipientApp.USER, 9L, scene("RIDER_ACCEPTED")).fileName());
+        verify(jedis, times(2)).close();
+    }
+
+    @Test
+    void transportPayloadsSelectSystemSoundAndRetainBusinessPayload() throws Exception {
+        String payload = scene("RIDER_ACCEPTED");
+        NativePushSound sound = sounds.forTest(RecipientApp.USER, "en", payload);
+        var body = JSON.parseObject(ApnsPushSender.buildBody(RecipientApp.USER, "title", "body", payload, sound));
+        assertEquals("rider_accepted_en.wav", body.getJSONObject("aps").getString("sound"));
+        assertEquals(sound.fileName(), body.getString("sound"));
+        assertEquals(payload, body.getString("payload"));
+        Message message = FcmPushSender.buildMessage(RecipientApp.USER, "token", "title", "body", payload, sound);
+        Object notification = field(field(message, "androidConfig"), "notification");
+        assertEquals("rider_accepted_en", field(notification, "sound"));
+        assertEquals(Boolean.FALSE, field(notification, "defaultSound"));
+        assertEquals(sound.channelId(), field(notification, "channelId"));
+        Map<?, ?> data = (Map<?, ?>) field(message, "data");
+        assertEquals(payload, data.get("payload"));
+        assertEquals(sound.fileName(), data.get("sound"));
+        assertEquals(sound.channelId(), data.get("channelId"));
+    }
+
+    @Test
+    void businessRoutingUsesSoundOnlyForNativeAndKeepsUniPayload() {
+        BusinessPushProperties properties = new BusinessPushProperties();
+        IInfoUserService users = mock(IInfoUserService.class);
+        FcmPushSender fcm = mock(FcmPushSender.class);
+        ApnsPushSender apns = mock(ApnsPushSender.class);
+        PayPush uni = mock(PayPush.class);
+        BusinessPushService service = new BusinessPushService(properties, users, fcm, apns, uni, sounds);
+        String payload = scene("RIDER_ACCEPTED");
+        service.send(RecipientApp.USER, 9L, "cid", "title", "body", payload);
+        verify(uni).apppush("cid", "title", "body", payload);
+        verifyNoInteractions(cache, users, fcm, apns);
+        properties.setMode(BusinessPushProperties.Mode.NATIVE);
+        InfoUser user = new InfoUser(); user.setCidType("android"); user.setDeviceToken("token");
+        when(users.getById(9L)).thenReturn(user);
+        when(cache.create()).thenReturn(jedis);
+        when(jedis.get("9")).thenReturn("en");
+        NativePushSound expected = sounds.forTest(RecipientApp.USER, "en", payload);
+        service.send(RecipientApp.USER, 9L, null, "title", "body", payload);
+        verify(fcm).send(RecipientApp.USER, "token", "title", "body", payload, expected);
+        user.setCidType("ios");
+        service.send(RecipientApp.USER, 9L, null, "title", "body", payload);
+        verify(apns).send(RecipientApp.USER, "token", "title", "body", payload, expected);
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"android", "ios"})
+    void anonymousTestCanSelectLanguageWithoutAnAccount(String platform) {
+        FcmPushSender fcm = mock(FcmPushSender.class);
+        ApnsPushSender apns = mock(ApnsPushSender.class);
+        NativePushTestController controller = new NativePushTestController(fcm, apns, sounds);
+        NativePushTestRequest request = new NativePushTestRequest();
+        request.setAppType("user"); request.setPlatform(platform); request.setDeviceToken("token");
+        request.setTitle("title"); request.setContent("body"); request.setLanguage("zhHant");
+        request.setPayload(scene("RIDER_ACCEPTED"));
+        try (var ignored = mockStatic(MessageUtils.class)) {
+            assertEquals(200, controller.send(request).get("code"));
+        }
+        NativePushSound expected = sounds.forTest(RecipientApp.USER, "zhHant", request.getPayload());
+        if ("android".equals(platform)) {
+            verify(fcm).sendForTest(RecipientApp.USER, "token", "title", "body", request.getPayload(), expected);
+        } else {
+            verify(apns).sendForTest(RecipientApp.USER, "token", "title", "body", request.getPayload(), expected);
+        }
+        verifyNoInteractions(cache);
+    }
+
+    private static String scene(String value) {
+        return "{\"eventScene\":\"" + value + "\"}";
+    }
+
+    private static Object field(Object object, String name) throws Exception {
+        Field field = object.getClass().getDeclaredField(name);
+        field.setAccessible(true);
+        return field.get(object);
+    }
+}

+ 2 - 1
ruoyi-admin/src/test/java/com/ruoyi/app/push/NativePushTestControllerTest.java

@@ -16,7 +16,8 @@ import static org.mockito.Mockito.verify;
 class NativePushTestControllerTest {
 class NativePushTestControllerTest {
     private final FcmPushSender fcm = mock(FcmPushSender.class);
     private final FcmPushSender fcm = mock(FcmPushSender.class);
     private final ApnsPushSender apns = mock(ApnsPushSender.class);
     private final ApnsPushSender apns = mock(ApnsPushSender.class);
-    private final NativePushTestController controller = new NativePushTestController(fcm, apns);
+    private final NativePushTestController controller = new NativePushTestController(fcm, apns,
+            mock(NativePushSoundResolver.class));
 
 
     @Test
     @Test
     void androidRequestSendsDirectlyToFcmWithoutAccountLookup() {
     void androidRequestSendsDirectlyToFcmWithoutAccountLookup() {

+ 4 - 0
specs/033-native-push-routing/plan.md

@@ -1,5 +1,9 @@
 # 可切换原生业务推送 Implementation Plan
 # 可切换原生业务推送 Implementation Plan
 
 
+## 2026-10-26 追加:用户原生系统语音
+
+在已有发送器增加可选 NativePushSound 重载,由 BusinessPushService 在 native 分支按九场景/七语种解析;uni 分支不读取语言或声音。使用现有 /changeLanguages 的同源 Jedis 值,未知语言/缓存错误回退繁中并释放连接。FCM 配置固定声音渠道和资源名,APNs 配置随包 WAV;另给前台提供 sound。测试入口可选 language,原有请求兼容。63 个 WAV 和 manifest 随规格交付 App,不放后端 classpath,不改数据库/配置或 App。按 035 T016–T019 完成定向测试、package、资源与差异检查;真机另外验收。
+
 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
 
 
 **Goal:** 在不改变默认 uniPush 行为的前提下,为当前在用的普通业务通知增加可通过服务端配置和重启启用的三端 Android FCM / iOS APNs 原生发送能力。
 **Goal:** 在不改变默认 uniPush 行为的前提下,为当前在用的普通业务通知增加可通过服务端配置和重启启用的三端 Android FCM / iOS APNs 原生发送能力。

+ 2 - 0
specs/033-native-push-routing/quickstart.md

@@ -1,5 +1,7 @@
 # 普通业务推送:原生通道部署与回退
 # 普通业务推送:原生通道部署与回退
 
 
+> **2026-10-26 声音接入补充:** 用户原生通知现支持按场景/语种选择前台、后台及锁屏声音;App 需携带 WAV 并初始化 Android 渠道。完整资源、字段与测试示例见 [035 原生声音接入](../035-user-notification-voice/native-sound-integration.md)。匿名 POST /system/push/testNative 可选 language,场景仍放原 payload JSON 字符串,未知语言默认繁中;原生测试不改变默认 uni 模式。商家/骑手原声音保持,旧请求未含场景继续默认声音。
+
 **状态(2026-09-29)**:服务端具备 FCM/APNs 路由能力,默认 `uni`,**尚未启用原生推送**。本功能不处理音视频通话,也不包含 App 端 token 获取、上报和展示改造;未完成真机验证前不要切换生产环境。
 **状态(2026-09-29)**:服务端具备 FCM/APNs 路由能力,默认 `uni`,**尚未启用原生推送**。本功能不处理音视频通话,也不包含 App 端 token 获取、上报和展示改造;未完成真机验证前不要切换生产环境。
 
 
 ## 配置与凭据
 ## 配置与凭据

+ 4 - 0
specs/033-native-push-routing/spec.md

@@ -1,5 +1,9 @@
 # Feature Specification: 可切换的原生业务推送通道
 # Feature Specification: 可切换的原生业务推送通道
 
 
+## 2026-10-26 原生用户系统语音补充
+
+原生用户通知支持九个明确场景、七语种随包 WAV 声音,语言未知默认繁中。FCM 使用 android.notification.sound/channel_id 与 data.sound/channelId;APNs 使用 aps.sound 与顶层 sound;payload 原样保留。音频与渠道不从服务端下载,App 必须预先打包/初始化;uni、商家/骑手及原生骑手批量默认声音不变。匿名测试新增可选 language,仍不切换业务模式。完整契约及资源见 [035 原生声音接入](../035-user-notification-voice/native-sound-integration.md)。后台/锁屏由系统处理,真机效果另行验收。
+
 **Feature Branch**: `033-native-push-routing`
 **Feature Branch**: `033-native-push-routing`
 
 
 **Created**: 2026-09-29
 **Created**: 2026-09-29

+ 10 - 0
specs/033-native-push-routing/tasks.md

@@ -1,5 +1,15 @@
 # Tasks: 可切换的原生业务推送通道
 # Tasks: 可切换的原生业务推送通道
 
 
+## 2026-10-26 原生用户语音追加
+
+**进度:实现中(本地验证已通过,未提交)。**
+
+- [x] T025 对齐 035 新增用户原生 sound 解析及 FCM/APNs 重载,测试入口支持可选 language;uni 与原商家/骑手声音保持。
+- [x] T026 交付 63 个 WAV 及 manifest、App 渠道/前台播放/系统声音和 Apipost 接入说明。
+- [x] T027 执行 JDK 21 推送与闪送通知定向测试、package、音频元数据和差异检查,记录真实未验证项;验证内容与 035 T019 共用。
+
+验证记录:JDK 21 定向 `*Push*Test,FlashDeliveryNotificationServiceTest` 共 141 项全部通过,package 退出 0;63 WAV 编码/时长/manifest 映射及 git diff --check 通过。未执行完整回归、部署、真实云端投递或 App 真机播放,未提交。详细记录见 035 T019。
+
 **Input**: `specs/033-native-push-routing/spec.md`、`plan.md`
 **Input**: `specs/033-native-push-routing/spec.md`、`plan.md`
 
 
 **Execution**: 当前 `test-202609v2` 分支;不创建新分支。每项先写并运行失败测试,再实现、验证、核对暂存范围并提交。默认模式始终为 `uni`。
 **Execution**: 当前 `test-202609v2` 分支;不创建新分支。每项先写并运行失败测试,再实现、验证、核对暂存范围并提交。默认模式始终为 `uni`。

+ 7 - 0
specs/035-user-notification-voice/assets/native-wav/README.md

@@ -0,0 +1,7 @@
+# 原生系统通知音频
+
+63 个 WAV 对应 9 场景 × en/id/ms/th/tl/zh-Hans/zh-Hant。编码为 16-bit Linear PCM、16 kHz、单声道,iOS 和 Android 共用文件。
+
+原 MP3 目录保留不变;简繁中文分别选择 zh-Hans/zh-Hant 源音频。文件名、原文件名、语种、Android 资源名与渠道 ID 见 [manifest.json](manifest.json)。未知语言默认 zh-Hant,未知场景沿用原默认声音。
+
+这些是交付 App 随包携带的资源,不是后端运行时下载或加载的声音。iOS 放入用户 App bundle,Android 放入 res/raw 并按清单预建渠道;详细说明见 [原生声音接入](../../native-sound-integration.md)。

BIN
specs/035-user-notification-voice/assets/native-wav/flash_delivery_stop_completed_en.wav


BIN
specs/035-user-notification-voice/assets/native-wav/flash_delivery_stop_completed_id.wav


BIN
specs/035-user-notification-voice/assets/native-wav/flash_delivery_stop_completed_ms.wav


BIN
specs/035-user-notification-voice/assets/native-wav/flash_delivery_stop_completed_th.wav


BIN
specs/035-user-notification-voice/assets/native-wav/flash_delivery_stop_completed_tl.wav


BIN
specs/035-user-notification-voice/assets/native-wav/flash_delivery_stop_completed_zh_hans.wav


BIN
specs/035-user-notification-voice/assets/native-wav/flash_delivery_stop_completed_zh_hant.wav


BIN
specs/035-user-notification-voice/assets/native-wav/flash_pickup_stop_completed_en.wav


BIN
specs/035-user-notification-voice/assets/native-wav/flash_pickup_stop_completed_id.wav


BIN
specs/035-user-notification-voice/assets/native-wav/flash_pickup_stop_completed_ms.wav


BIN
specs/035-user-notification-voice/assets/native-wav/flash_pickup_stop_completed_th.wav


BIN
specs/035-user-notification-voice/assets/native-wav/flash_pickup_stop_completed_tl.wav


BIN
specs/035-user-notification-voice/assets/native-wav/flash_pickup_stop_completed_zh_hans.wav


BIN
specs/035-user-notification-voice/assets/native-wav/flash_pickup_stop_completed_zh_hant.wav


+ 513 - 0
specs/035-user-notification-voice/assets/native-wav/manifest.json

@@ -0,0 +1,513 @@
+{
+  "format": "WAV",
+  "codec": "pcm_s16le",
+  "sampleRateHz": 16000,
+  "channels": 1,
+  "defaultLanguage": "zh-Hant",
+  "entries": [
+    {
+      "scene": "FLASH_DELIVERY_STOP_COMPLETED",
+      "language": "en",
+      "sourceFile": "FLASH_DELIVERY_STOP_COMPLETED_en.mp3",
+      "fileName": "flash_delivery_stop_completed_en.wav",
+      "androidResource": "flash_delivery_stop_completed_en",
+      "androidChannelId": "user_voice_flash_delivery_stop_completed_en_v1"
+    },
+    {
+      "scene": "FLASH_DELIVERY_STOP_COMPLETED",
+      "language": "id",
+      "sourceFile": "FLASH_DELIVERY_STOP_COMPLETED_id.mp3",
+      "fileName": "flash_delivery_stop_completed_id.wav",
+      "androidResource": "flash_delivery_stop_completed_id",
+      "androidChannelId": "user_voice_flash_delivery_stop_completed_id_v1"
+    },
+    {
+      "scene": "FLASH_DELIVERY_STOP_COMPLETED",
+      "language": "ms",
+      "sourceFile": "FLASH_DELIVERY_STOP_COMPLETED_ms.mp3",
+      "fileName": "flash_delivery_stop_completed_ms.wav",
+      "androidResource": "flash_delivery_stop_completed_ms",
+      "androidChannelId": "user_voice_flash_delivery_stop_completed_ms_v1"
+    },
+    {
+      "scene": "FLASH_DELIVERY_STOP_COMPLETED",
+      "language": "th",
+      "sourceFile": "FLASH_DELIVERY_STOP_COMPLETED_th.mp3",
+      "fileName": "flash_delivery_stop_completed_th.wav",
+      "androidResource": "flash_delivery_stop_completed_th",
+      "androidChannelId": "user_voice_flash_delivery_stop_completed_th_v1"
+    },
+    {
+      "scene": "FLASH_DELIVERY_STOP_COMPLETED",
+      "language": "tl",
+      "sourceFile": "FLASH_DELIVERY_STOP_COMPLETED_tl.mp3",
+      "fileName": "flash_delivery_stop_completed_tl.wav",
+      "androidResource": "flash_delivery_stop_completed_tl",
+      "androidChannelId": "user_voice_flash_delivery_stop_completed_tl_v1"
+    },
+    {
+      "scene": "FLASH_DELIVERY_STOP_COMPLETED",
+      "language": "zh-Hans",
+      "sourceFile": "FLASH_DELIVERY_STOP_COMPLETED_zh-Hans.mp3",
+      "fileName": "flash_delivery_stop_completed_zh_hans.wav",
+      "androidResource": "flash_delivery_stop_completed_zh_hans",
+      "androidChannelId": "user_voice_flash_delivery_stop_completed_zh_hans_v1"
+    },
+    {
+      "scene": "FLASH_DELIVERY_STOP_COMPLETED",
+      "language": "zh-Hant",
+      "sourceFile": "FLASH_DELIVERY_STOP_COMPLETED_zh-Hant.mp3",
+      "fileName": "flash_delivery_stop_completed_zh_hant.wav",
+      "androidResource": "flash_delivery_stop_completed_zh_hant",
+      "androidChannelId": "user_voice_flash_delivery_stop_completed_zh_hant_v1"
+    },
+    {
+      "scene": "FLASH_PICKUP_STOP_COMPLETED",
+      "language": "en",
+      "sourceFile": "FLASH_PICKUP_STOP_COMPLETED_en.mp3",
+      "fileName": "flash_pickup_stop_completed_en.wav",
+      "androidResource": "flash_pickup_stop_completed_en",
+      "androidChannelId": "user_voice_flash_pickup_stop_completed_en_v1"
+    },
+    {
+      "scene": "FLASH_PICKUP_STOP_COMPLETED",
+      "language": "id",
+      "sourceFile": "FLASH_PICKUP_STOP_COMPLETED_id.mp3",
+      "fileName": "flash_pickup_stop_completed_id.wav",
+      "androidResource": "flash_pickup_stop_completed_id",
+      "androidChannelId": "user_voice_flash_pickup_stop_completed_id_v1"
+    },
+    {
+      "scene": "FLASH_PICKUP_STOP_COMPLETED",
+      "language": "ms",
+      "sourceFile": "FLASH_PICKUP_STOP_COMPLETED_ms.mp3",
+      "fileName": "flash_pickup_stop_completed_ms.wav",
+      "androidResource": "flash_pickup_stop_completed_ms",
+      "androidChannelId": "user_voice_flash_pickup_stop_completed_ms_v1"
+    },
+    {
+      "scene": "FLASH_PICKUP_STOP_COMPLETED",
+      "language": "th",
+      "sourceFile": "FLASH_PICKUP_STOP_COMPLETED_th.mp3",
+      "fileName": "flash_pickup_stop_completed_th.wav",
+      "androidResource": "flash_pickup_stop_completed_th",
+      "androidChannelId": "user_voice_flash_pickup_stop_completed_th_v1"
+    },
+    {
+      "scene": "FLASH_PICKUP_STOP_COMPLETED",
+      "language": "tl",
+      "sourceFile": "FLASH_PICKUP_STOP_COMPLETED_tl.mp3",
+      "fileName": "flash_pickup_stop_completed_tl.wav",
+      "androidResource": "flash_pickup_stop_completed_tl",
+      "androidChannelId": "user_voice_flash_pickup_stop_completed_tl_v1"
+    },
+    {
+      "scene": "FLASH_PICKUP_STOP_COMPLETED",
+      "language": "zh-Hans",
+      "sourceFile": "FLASH_PICKUP_STOP_COMPLETED_zh-Hans.mp3",
+      "fileName": "flash_pickup_stop_completed_zh_hans.wav",
+      "androidResource": "flash_pickup_stop_completed_zh_hans",
+      "androidChannelId": "user_voice_flash_pickup_stop_completed_zh_hans_v1"
+    },
+    {
+      "scene": "FLASH_PICKUP_STOP_COMPLETED",
+      "language": "zh-Hant",
+      "sourceFile": "FLASH_PICKUP_STOP_COMPLETED_zh-Hant.mp3",
+      "fileName": "flash_pickup_stop_completed_zh_hant.wav",
+      "androidResource": "flash_pickup_stop_completed_zh_hant",
+      "androidChannelId": "user_voice_flash_pickup_stop_completed_zh_hant_v1"
+    },
+    {
+      "scene": "MERCHANT_ACCEPTED",
+      "language": "en",
+      "sourceFile": "MERCHANT_ACCEPTED_en.mp3",
+      "fileName": "merchant_accepted_en.wav",
+      "androidResource": "merchant_accepted_en",
+      "androidChannelId": "user_voice_merchant_accepted_en_v1"
+    },
+    {
+      "scene": "MERCHANT_ACCEPTED",
+      "language": "id",
+      "sourceFile": "MERCHANT_ACCEPTED_id.mp3",
+      "fileName": "merchant_accepted_id.wav",
+      "androidResource": "merchant_accepted_id",
+      "androidChannelId": "user_voice_merchant_accepted_id_v1"
+    },
+    {
+      "scene": "MERCHANT_ACCEPTED",
+      "language": "ms",
+      "sourceFile": "MERCHANT_ACCEPTED_ms.mp3",
+      "fileName": "merchant_accepted_ms.wav",
+      "androidResource": "merchant_accepted_ms",
+      "androidChannelId": "user_voice_merchant_accepted_ms_v1"
+    },
+    {
+      "scene": "MERCHANT_ACCEPTED",
+      "language": "th",
+      "sourceFile": "MERCHANT_ACCEPTED_th.mp3",
+      "fileName": "merchant_accepted_th.wav",
+      "androidResource": "merchant_accepted_th",
+      "androidChannelId": "user_voice_merchant_accepted_th_v1"
+    },
+    {
+      "scene": "MERCHANT_ACCEPTED",
+      "language": "tl",
+      "sourceFile": "MERCHANT_ACCEPTED_tl.mp3",
+      "fileName": "merchant_accepted_tl.wav",
+      "androidResource": "merchant_accepted_tl",
+      "androidChannelId": "user_voice_merchant_accepted_tl_v1"
+    },
+    {
+      "scene": "MERCHANT_ACCEPTED",
+      "language": "zh-Hans",
+      "sourceFile": "MERCHANT_ACCEPTED_zh-Hans.mp3",
+      "fileName": "merchant_accepted_zh_hans.wav",
+      "androidResource": "merchant_accepted_zh_hans",
+      "androidChannelId": "user_voice_merchant_accepted_zh_hans_v1"
+    },
+    {
+      "scene": "MERCHANT_ACCEPTED",
+      "language": "zh-Hant",
+      "sourceFile": "MERCHANT_ACCEPTED_zh-Hant.mp3",
+      "fileName": "merchant_accepted_zh_hant.wav",
+      "androidResource": "merchant_accepted_zh_hant",
+      "androidChannelId": "user_voice_merchant_accepted_zh_hant_v1"
+    },
+    {
+      "scene": "MERCHANT_READY",
+      "language": "en",
+      "sourceFile": "MERCHANT_READY_en.mp3",
+      "fileName": "merchant_ready_en.wav",
+      "androidResource": "merchant_ready_en",
+      "androidChannelId": "user_voice_merchant_ready_en_v1"
+    },
+    {
+      "scene": "MERCHANT_READY",
+      "language": "id",
+      "sourceFile": "MERCHANT_READY_id.mp3",
+      "fileName": "merchant_ready_id.wav",
+      "androidResource": "merchant_ready_id",
+      "androidChannelId": "user_voice_merchant_ready_id_v1"
+    },
+    {
+      "scene": "MERCHANT_READY",
+      "language": "ms",
+      "sourceFile": "MERCHANT_READY_ms.mp3",
+      "fileName": "merchant_ready_ms.wav",
+      "androidResource": "merchant_ready_ms",
+      "androidChannelId": "user_voice_merchant_ready_ms_v1"
+    },
+    {
+      "scene": "MERCHANT_READY",
+      "language": "th",
+      "sourceFile": "MERCHANT_READY_th.mp3",
+      "fileName": "merchant_ready_th.wav",
+      "androidResource": "merchant_ready_th",
+      "androidChannelId": "user_voice_merchant_ready_th_v1"
+    },
+    {
+      "scene": "MERCHANT_READY",
+      "language": "tl",
+      "sourceFile": "MERCHANT_READY_tl.mp3",
+      "fileName": "merchant_ready_tl.wav",
+      "androidResource": "merchant_ready_tl",
+      "androidChannelId": "user_voice_merchant_ready_tl_v1"
+    },
+    {
+      "scene": "MERCHANT_READY",
+      "language": "zh-Hans",
+      "sourceFile": "MERCHANT_READY_zh-Hans.mp3",
+      "fileName": "merchant_ready_zh_hans.wav",
+      "androidResource": "merchant_ready_zh_hans",
+      "androidChannelId": "user_voice_merchant_ready_zh_hans_v1"
+    },
+    {
+      "scene": "MERCHANT_READY",
+      "language": "zh-Hant",
+      "sourceFile": "MERCHANT_READY_zh-Hant.mp3",
+      "fileName": "merchant_ready_zh_hant.wav",
+      "androidResource": "merchant_ready_zh_hant",
+      "androidChannelId": "user_voice_merchant_ready_zh_hant_v1"
+    },
+    {
+      "scene": "ORDER_CANCELLED",
+      "language": "en",
+      "sourceFile": "ORDER_CANCELLED_en.mp3",
+      "fileName": "order_cancelled_en.wav",
+      "androidResource": "order_cancelled_en",
+      "androidChannelId": "user_voice_order_cancelled_en_v1"
+    },
+    {
+      "scene": "ORDER_CANCELLED",
+      "language": "id",
+      "sourceFile": "ORDER_CANCELLED_id.mp3",
+      "fileName": "order_cancelled_id.wav",
+      "androidResource": "order_cancelled_id",
+      "androidChannelId": "user_voice_order_cancelled_id_v1"
+    },
+    {
+      "scene": "ORDER_CANCELLED",
+      "language": "ms",
+      "sourceFile": "ORDER_CANCELLED_ms.mp3",
+      "fileName": "order_cancelled_ms.wav",
+      "androidResource": "order_cancelled_ms",
+      "androidChannelId": "user_voice_order_cancelled_ms_v1"
+    },
+    {
+      "scene": "ORDER_CANCELLED",
+      "language": "th",
+      "sourceFile": "ORDER_CANCELLED_th.mp3",
+      "fileName": "order_cancelled_th.wav",
+      "androidResource": "order_cancelled_th",
+      "androidChannelId": "user_voice_order_cancelled_th_v1"
+    },
+    {
+      "scene": "ORDER_CANCELLED",
+      "language": "tl",
+      "sourceFile": "ORDER_CANCELLED_tl.mp3",
+      "fileName": "order_cancelled_tl.wav",
+      "androidResource": "order_cancelled_tl",
+      "androidChannelId": "user_voice_order_cancelled_tl_v1"
+    },
+    {
+      "scene": "ORDER_CANCELLED",
+      "language": "zh-Hans",
+      "sourceFile": "ORDER_CANCELLED_zh-Hans.mp3",
+      "fileName": "order_cancelled_zh_hans.wav",
+      "androidResource": "order_cancelled_zh_hans",
+      "androidChannelId": "user_voice_order_cancelled_zh_hans_v1"
+    },
+    {
+      "scene": "ORDER_CANCELLED",
+      "language": "zh-Hant",
+      "sourceFile": "ORDER_CANCELLED_zh-Hant.mp3",
+      "fileName": "order_cancelled_zh_hant.wav",
+      "androidResource": "order_cancelled_zh_hant",
+      "androidChannelId": "user_voice_order_cancelled_zh_hant_v1"
+    },
+    {
+      "scene": "ORDER_DELIVERED",
+      "language": "en",
+      "sourceFile": "ORDER_DELIVERED_en.mp3",
+      "fileName": "order_delivered_en.wav",
+      "androidResource": "order_delivered_en",
+      "androidChannelId": "user_voice_order_delivered_en_v1"
+    },
+    {
+      "scene": "ORDER_DELIVERED",
+      "language": "id",
+      "sourceFile": "ORDER_DELIVERED_id.mp3",
+      "fileName": "order_delivered_id.wav",
+      "androidResource": "order_delivered_id",
+      "androidChannelId": "user_voice_order_delivered_id_v1"
+    },
+    {
+      "scene": "ORDER_DELIVERED",
+      "language": "ms",
+      "sourceFile": "ORDER_DELIVERED_ms.mp3",
+      "fileName": "order_delivered_ms.wav",
+      "androidResource": "order_delivered_ms",
+      "androidChannelId": "user_voice_order_delivered_ms_v1"
+    },
+    {
+      "scene": "ORDER_DELIVERED",
+      "language": "th",
+      "sourceFile": "ORDER_DELIVERED_th.mp3",
+      "fileName": "order_delivered_th.wav",
+      "androidResource": "order_delivered_th",
+      "androidChannelId": "user_voice_order_delivered_th_v1"
+    },
+    {
+      "scene": "ORDER_DELIVERED",
+      "language": "tl",
+      "sourceFile": "ORDER_DELIVERED_tl.mp3",
+      "fileName": "order_delivered_tl.wav",
+      "androidResource": "order_delivered_tl",
+      "androidChannelId": "user_voice_order_delivered_tl_v1"
+    },
+    {
+      "scene": "ORDER_DELIVERED",
+      "language": "zh-Hans",
+      "sourceFile": "ORDER_DELIVERED_zh-Hans.mp3",
+      "fileName": "order_delivered_zh_hans.wav",
+      "androidResource": "order_delivered_zh_hans",
+      "androidChannelId": "user_voice_order_delivered_zh_hans_v1"
+    },
+    {
+      "scene": "ORDER_DELIVERED",
+      "language": "zh-Hant",
+      "sourceFile": "ORDER_DELIVERED_zh-Hant.mp3",
+      "fileName": "order_delivered_zh_hant.wav",
+      "androidResource": "order_delivered_zh_hant",
+      "androidChannelId": "user_voice_order_delivered_zh_hant_v1"
+    },
+    {
+      "scene": "ORDER_DELIVERING",
+      "language": "en",
+      "sourceFile": "ORDER_DELIVERING_en.mp3",
+      "fileName": "order_delivering_en.wav",
+      "androidResource": "order_delivering_en",
+      "androidChannelId": "user_voice_order_delivering_en_v1"
+    },
+    {
+      "scene": "ORDER_DELIVERING",
+      "language": "id",
+      "sourceFile": "ORDER_DELIVERING_id.mp3",
+      "fileName": "order_delivering_id.wav",
+      "androidResource": "order_delivering_id",
+      "androidChannelId": "user_voice_order_delivering_id_v1"
+    },
+    {
+      "scene": "ORDER_DELIVERING",
+      "language": "ms",
+      "sourceFile": "ORDER_DELIVERING_ms.mp3",
+      "fileName": "order_delivering_ms.wav",
+      "androidResource": "order_delivering_ms",
+      "androidChannelId": "user_voice_order_delivering_ms_v1"
+    },
+    {
+      "scene": "ORDER_DELIVERING",
+      "language": "th",
+      "sourceFile": "ORDER_DELIVERING_th.mp3",
+      "fileName": "order_delivering_th.wav",
+      "androidResource": "order_delivering_th",
+      "androidChannelId": "user_voice_order_delivering_th_v1"
+    },
+    {
+      "scene": "ORDER_DELIVERING",
+      "language": "tl",
+      "sourceFile": "ORDER_DELIVERING_tl.mp3",
+      "fileName": "order_delivering_tl.wav",
+      "androidResource": "order_delivering_tl",
+      "androidChannelId": "user_voice_order_delivering_tl_v1"
+    },
+    {
+      "scene": "ORDER_DELIVERING",
+      "language": "zh-Hans",
+      "sourceFile": "ORDER_DELIVERING_zh-Hans.mp3",
+      "fileName": "order_delivering_zh_hans.wav",
+      "androidResource": "order_delivering_zh_hans",
+      "androidChannelId": "user_voice_order_delivering_zh_hans_v1"
+    },
+    {
+      "scene": "ORDER_DELIVERING",
+      "language": "zh-Hant",
+      "sourceFile": "ORDER_DELIVERING_zh-Hant.mp3",
+      "fileName": "order_delivering_zh_hant.wav",
+      "androidResource": "order_delivering_zh_hant",
+      "androidChannelId": "user_voice_order_delivering_zh_hant_v1"
+    },
+    {
+      "scene": "PAYMENT_SUCCEEDED",
+      "language": "en",
+      "sourceFile": "PAYMENT_SUCCEEDED_en.mp3",
+      "fileName": "payment_succeeded_en.wav",
+      "androidResource": "payment_succeeded_en",
+      "androidChannelId": "user_voice_payment_succeeded_en_v1"
+    },
+    {
+      "scene": "PAYMENT_SUCCEEDED",
+      "language": "id",
+      "sourceFile": "PAYMENT_SUCCEEDED_id.mp3",
+      "fileName": "payment_succeeded_id.wav",
+      "androidResource": "payment_succeeded_id",
+      "androidChannelId": "user_voice_payment_succeeded_id_v1"
+    },
+    {
+      "scene": "PAYMENT_SUCCEEDED",
+      "language": "ms",
+      "sourceFile": "PAYMENT_SUCCEEDED_ms.mp3",
+      "fileName": "payment_succeeded_ms.wav",
+      "androidResource": "payment_succeeded_ms",
+      "androidChannelId": "user_voice_payment_succeeded_ms_v1"
+    },
+    {
+      "scene": "PAYMENT_SUCCEEDED",
+      "language": "th",
+      "sourceFile": "PAYMENT_SUCCEEDED_th.mp3",
+      "fileName": "payment_succeeded_th.wav",
+      "androidResource": "payment_succeeded_th",
+      "androidChannelId": "user_voice_payment_succeeded_th_v1"
+    },
+    {
+      "scene": "PAYMENT_SUCCEEDED",
+      "language": "tl",
+      "sourceFile": "PAYMENT_SUCCEEDED_tl.mp3",
+      "fileName": "payment_succeeded_tl.wav",
+      "androidResource": "payment_succeeded_tl",
+      "androidChannelId": "user_voice_payment_succeeded_tl_v1"
+    },
+    {
+      "scene": "PAYMENT_SUCCEEDED",
+      "language": "zh-Hans",
+      "sourceFile": "PAYMENT_SUCCEEDED_zh-Hans.mp3",
+      "fileName": "payment_succeeded_zh_hans.wav",
+      "androidResource": "payment_succeeded_zh_hans",
+      "androidChannelId": "user_voice_payment_succeeded_zh_hans_v1"
+    },
+    {
+      "scene": "PAYMENT_SUCCEEDED",
+      "language": "zh-Hant",
+      "sourceFile": "PAYMENT_SUCCEEDED_zh-Hant.mp3",
+      "fileName": "payment_succeeded_zh_hant.wav",
+      "androidResource": "payment_succeeded_zh_hant",
+      "androidChannelId": "user_voice_payment_succeeded_zh_hant_v1"
+    },
+    {
+      "scene": "RIDER_ACCEPTED",
+      "language": "en",
+      "sourceFile": "RIDER_ACCEPTED_en.mp3",
+      "fileName": "rider_accepted_en.wav",
+      "androidResource": "rider_accepted_en",
+      "androidChannelId": "user_voice_rider_accepted_en_v1"
+    },
+    {
+      "scene": "RIDER_ACCEPTED",
+      "language": "id",
+      "sourceFile": "RIDER_ACCEPTED_id.mp3",
+      "fileName": "rider_accepted_id.wav",
+      "androidResource": "rider_accepted_id",
+      "androidChannelId": "user_voice_rider_accepted_id_v1"
+    },
+    {
+      "scene": "RIDER_ACCEPTED",
+      "language": "ms",
+      "sourceFile": "RIDER_ACCEPTED_ms.mp3",
+      "fileName": "rider_accepted_ms.wav",
+      "androidResource": "rider_accepted_ms",
+      "androidChannelId": "user_voice_rider_accepted_ms_v1"
+    },
+    {
+      "scene": "RIDER_ACCEPTED",
+      "language": "th",
+      "sourceFile": "RIDER_ACCEPTED_th.mp3",
+      "fileName": "rider_accepted_th.wav",
+      "androidResource": "rider_accepted_th",
+      "androidChannelId": "user_voice_rider_accepted_th_v1"
+    },
+    {
+      "scene": "RIDER_ACCEPTED",
+      "language": "tl",
+      "sourceFile": "RIDER_ACCEPTED_tl.mp3",
+      "fileName": "rider_accepted_tl.wav",
+      "androidResource": "rider_accepted_tl",
+      "androidChannelId": "user_voice_rider_accepted_tl_v1"
+    },
+    {
+      "scene": "RIDER_ACCEPTED",
+      "language": "zh-Hans",
+      "sourceFile": "RIDER_ACCEPTED_zh-Hans.mp3",
+      "fileName": "rider_accepted_zh_hans.wav",
+      "androidResource": "rider_accepted_zh_hans",
+      "androidChannelId": "user_voice_rider_accepted_zh_hans_v1"
+    },
+    {
+      "scene": "RIDER_ACCEPTED",
+      "language": "zh-Hant",
+      "sourceFile": "RIDER_ACCEPTED_zh-Hant.mp3",
+      "fileName": "rider_accepted_zh_hant.wav",
+      "androidResource": "rider_accepted_zh_hant",
+      "androidChannelId": "user_voice_rider_accepted_zh_hant_v1"
+    }
+  ]
+}

BIN
specs/035-user-notification-voice/assets/native-wav/merchant_accepted_en.wav


BIN
specs/035-user-notification-voice/assets/native-wav/merchant_accepted_id.wav


BIN
specs/035-user-notification-voice/assets/native-wav/merchant_accepted_ms.wav


BIN
specs/035-user-notification-voice/assets/native-wav/merchant_accepted_th.wav


BIN
specs/035-user-notification-voice/assets/native-wav/merchant_accepted_tl.wav


BIN
specs/035-user-notification-voice/assets/native-wav/merchant_accepted_zh_hans.wav


BIN
specs/035-user-notification-voice/assets/native-wav/merchant_accepted_zh_hant.wav


BIN
specs/035-user-notification-voice/assets/native-wav/merchant_ready_en.wav


BIN
specs/035-user-notification-voice/assets/native-wav/merchant_ready_id.wav


BIN
specs/035-user-notification-voice/assets/native-wav/merchant_ready_ms.wav


BIN
specs/035-user-notification-voice/assets/native-wav/merchant_ready_th.wav


BIN
specs/035-user-notification-voice/assets/native-wav/merchant_ready_tl.wav


BIN
specs/035-user-notification-voice/assets/native-wav/merchant_ready_zh_hans.wav


BIN
specs/035-user-notification-voice/assets/native-wav/merchant_ready_zh_hant.wav


BIN
specs/035-user-notification-voice/assets/native-wav/order_cancelled_en.wav


BIN
specs/035-user-notification-voice/assets/native-wav/order_cancelled_id.wav


BIN
specs/035-user-notification-voice/assets/native-wav/order_cancelled_ms.wav


BIN
specs/035-user-notification-voice/assets/native-wav/order_cancelled_th.wav


BIN
specs/035-user-notification-voice/assets/native-wav/order_cancelled_tl.wav


BIN
specs/035-user-notification-voice/assets/native-wav/order_cancelled_zh_hans.wav


BIN
specs/035-user-notification-voice/assets/native-wav/order_cancelled_zh_hant.wav


BIN
specs/035-user-notification-voice/assets/native-wav/order_delivered_en.wav


BIN
specs/035-user-notification-voice/assets/native-wav/order_delivered_id.wav


BIN
specs/035-user-notification-voice/assets/native-wav/order_delivered_ms.wav


BIN
specs/035-user-notification-voice/assets/native-wav/order_delivered_th.wav


BIN
specs/035-user-notification-voice/assets/native-wav/order_delivered_tl.wav


BIN
specs/035-user-notification-voice/assets/native-wav/order_delivered_zh_hans.wav


BIN
specs/035-user-notification-voice/assets/native-wav/order_delivered_zh_hant.wav


BIN
specs/035-user-notification-voice/assets/native-wav/order_delivering_en.wav


BIN
specs/035-user-notification-voice/assets/native-wav/order_delivering_id.wav


BIN
specs/035-user-notification-voice/assets/native-wav/order_delivering_ms.wav


BIN
specs/035-user-notification-voice/assets/native-wav/order_delivering_th.wav


BIN
specs/035-user-notification-voice/assets/native-wav/order_delivering_tl.wav


BIN
specs/035-user-notification-voice/assets/native-wav/order_delivering_zh_hans.wav


BIN
specs/035-user-notification-voice/assets/native-wav/order_delivering_zh_hant.wav


BIN
specs/035-user-notification-voice/assets/native-wav/payment_succeeded_en.wav


BIN
specs/035-user-notification-voice/assets/native-wav/payment_succeeded_id.wav


BIN
specs/035-user-notification-voice/assets/native-wav/payment_succeeded_ms.wav


BIN
specs/035-user-notification-voice/assets/native-wav/payment_succeeded_th.wav


BIN
specs/035-user-notification-voice/assets/native-wav/payment_succeeded_tl.wav


BIN
specs/035-user-notification-voice/assets/native-wav/payment_succeeded_zh_hans.wav


BIN
specs/035-user-notification-voice/assets/native-wav/payment_succeeded_zh_hant.wav


BIN
specs/035-user-notification-voice/assets/native-wav/rider_accepted_en.wav


BIN
specs/035-user-notification-voice/assets/native-wav/rider_accepted_id.wav


BIN
specs/035-user-notification-voice/assets/native-wav/rider_accepted_ms.wav


BIN
specs/035-user-notification-voice/assets/native-wav/rider_accepted_th.wav


BIN
specs/035-user-notification-voice/assets/native-wav/rider_accepted_tl.wav


BIN
specs/035-user-notification-voice/assets/native-wav/rider_accepted_zh_hans.wav


BIN
specs/035-user-notification-voice/assets/native-wav/rider_accepted_zh_hant.wav


+ 2 - 0
specs/035-user-notification-voice/frontend-integration.md

@@ -1,5 +1,7 @@
 # 用户前台语音提醒:前端接入文档
 # 用户前台语音提醒:前端接入文档
 
 
+> **2026-10-26 更新:** 原生 FCM/APNs 已追加前台、后台与锁屏语音参数及共用 WAV 资源。原生接入以 [native-sound-integration.md](native-sound-integration.md) 为准,覆盖下文历史“只有 eventScene、前端选语言、后台声音不变”的原生约束。uniPush 沿用下文旧契约。App 需打包音频并预建 Android 渠道,服务端参数不能代替这些步骤;真机播放尚未验证。
+
 **日期:**2026-10-09。
 **日期:**2026-10-09。
 
 
 **状态:**后端已验证,未部署;本地七场景接入、152 项定向测试及模块构建通过。App 实现与真实云端/真机联调由前端完成;示例不代表线上已返回该字段。
 **状态:**后端已验证,未部署;本地七场景接入、152 项定向测试及模块构建通过。App 实现与真实云端/真机联调由前端完成;示例不代表线上已返回该字段。

+ 105 - 0
specs/035-user-notification-voice/native-sound-integration.md

@@ -0,0 +1,105 @@
+# 原生通知语音接入(2026-10-26)
+
+本次追加覆盖 FCM/APNs 用户通知的前台、后台和锁屏语音。uniPush、消息中心 body、原业务 payload、商家/骑手默认声音保持原规则。不使用 VoIP,不新增通知或声音下载接口。服务端默认推送通道仍为 uni,测试入口直接使用原生发送。
+
+## 资源交付
+
+音频位于 [assets/native-wav](assets/native-wav/),完整文件和渠道映射见 [manifest.json](assets/native-wav/manifest.json)。63 个 WAV:9 场景 × 7 语种,16 kHz、单声道、16-bit Linear PCM,均不足 30 秒。由用户提供的 MP3 转换,原目录未修改;zh-CN/zh-TW 的语音分别统一选用提供的 zh-Hans/zh-Hant 源文件,不合并或覆盖原文件。
+
+| 场景字段 | 值 | 文件名前缀 |
+| --- | --- | --- |
+| eventScene | RIDER_ACCEPTED | rider_accepted |
+| eventScene | ORDER_DELIVERING | order_delivering |
+| eventScene | ORDER_DELIVERED | order_delivered |
+| eventScene | MERCHANT_ACCEPTED | merchant_accepted |
+| eventScene | MERCHANT_READY | merchant_ready |
+| eventScene | PAYMENT_SUCCEEDED | payment_succeeded |
+| eventScene | ORDER_CANCELLED | order_cancelled |
+| eventType | FLASH_PICKUP_STOP_COMPLETED | flash_pickup_stop_completed |
+| eventType | FLASH_DELIVERY_STOP_COMPLETED | flash_delivery_stop_completed |
+
+文件格式:`{场景小写}_{语言后缀}.wav`,例如 `rider_accepted_zh_hant.wav`。
+
+| 传入/已保存语言示例 | 资源后缀 |
+| --- | --- |
+| zhHant、zh-Hant、zh-TW、zh_TW、tw、zh-Hant-TW | zh_hant |
+| zhHans、zh-Hans、zh-CN、zh_CN、zh-Hans-CN | zh_hans |
+| en、en-US | en |
+| th、th-TH | th |
+| id、id-ID、in | id |
+| ms、ms-MY | ms |
+| tl、fil、fil-PH | tl |
+| 未设置、空白、未知(含 vi、未指定繁简的 zh) | zh_hant |
+
+业务通知读取现有 `GET /changeLanguages?lang=zhHant&id=用户ID` 保存的语言,使用与该接口相同的 Jedis DB 0 和原始用户 ID 键;App 在登录/切换语言时需调用既有接口。此处不改造该旧接口权限或签名。缓存不可用时通知仍发送,声音使用繁中。测试入口直接读请求 language,不查用户或语言缓存。缺场景/未知场景不猜语音,保留原默认通知声音。
+
+## iOS
+
+将 WAV 作为资源加入用户 App target,确认文件真正包含在安装包的 bundle 中;也可使用系统支持的 Library/Sounds 目录。后端 `aps.sound` 直接指定文件名,同时在 userInfo 顶层放 `sound`,供前台处理。
+
+```json
+{
+  "aps": {
+    "alert": {"title": "订单通知", "body": "骑手已接单"},
+    "sound": "rider_accepted_zh_hant.wav"
+  },
+  "sound": "rider_accepted_zh_hant.wav",
+  "payload": "{\"eventScene\":\"RIDER_ACCEPTED\"}"
+}
+```
+
+WAV 是 Apple 支持的系统通知声音格式,不需要再转 CAF;不能只改 MP3 扩展名。申请通知及声音权限。前台可在系统回调允许 sound,或按顶层 sound 手动播放,二选一以免双响。后台/锁屏由系统播放,点击通知、加载历史消息、恢复前台不补播。静音、专注模式及用户权限可能抑制声音,后端不能强制绕过。
+
+APNs 网关仍按现有 `apns-environment` 选择,与本功能无关;签名环境必须匹配 Token。测试接口成功仅表示推送服务接受,不表示手机已播放。
+
+## Android
+
+将 WAV 复制到用户 App 原生工程 `res/raw/`,文件名保持小写字母与下划线。首次启动时按 manifest 创建全部 63 个声音渠道,确保后台通知到达前渠道已存在。渠道 ID 为 `user_voice_{不含扩展名的资源名}_v1`,例如 `user_voice_rider_accepted_zh_hant_v1`。
+
+创建渠道时:`IMPORTANCE_HIGH`,sound URI 指向该 raw 资源,AudioAttributes 使用 `USAGE_NOTIFICATION`;渠道名称需由 App 国际化。后端发送 notification + data:
+
+```json
+{
+  "android": {
+    "priority": "high",
+    "notification": {
+      "sound": "rider_accepted_zh_hant",
+      "default_sound": false,
+      "channel_id": "user_voice_rider_accepted_zh_hant_v1"
+    }
+  },
+  "data": {
+    "sound": "rider_accepted_zh_hant.wav",
+    "channelId": "user_voice_rider_accepted_zh_hant_v1",
+    "payload": "{\"eventScene\":\"RIDER_ACCEPTED\"}"
+  }
+}
+```
+
+Android 8+ 以渠道声音为准,后端传 sound 无法覆盖已有渠道设置;渠道缺失可能回退默认渠道。渠道一旦创建便不能靠重新 setSound 修改,未来更换绑定声音需双方约定新版本渠道 ID。Android 13+ 申请通知权限。后台/锁屏使用系统通知;前台由 App 根据 data.sound 播放,或使用 data.channelId 发本地通知,二选一。用户关闭渠道声音时不能保证响铃。
+
+## Apipost 测试
+
+匿名 `POST /system/push/testNative`,`Content-Type: application/json`,不切换业务推送模式,不需要用户 ID。沿用已有 token 手填方式,新增可选 language;场景仍在 payload 中,payload 是字符串。
+
+```json
+{
+  "appType": "user",
+  "platform": "android",
+  "deviceToken": "填写手机当前原生Token",
+  "title": "语音测试",
+  "content": "骑手已接单",
+  "language": "zhHant",
+  "payload": "{\"eventScene\":\"RIDER_ACCEPTED\"}"
+}
+```
+
+iOS 将 platform 改为 ios 并填写对应 APNs Token。闪送站点示例:`payload` 使用 `{"eventType":"FLASH_PICKUP_STOP_COMPLETED"}` 的 JSON 字符串。省略 language 或传未知值,应使用繁中声音。老请求不含场景时仍按原默认声音发送。
+
+## 验收边界
+
+- 后端定向测试验证场景/语言映射、同一语言缓存源、缓存失败回退、FCM/APNs 载荷、匿名测试入口及 uni 行为隔离;音频逐个验证编码和时长。
+- App 需验证资源打包、渠道初始化及权限后,分别测试 iOS/Android 前台、后台和锁屏,检查正确语种、单次播放、点击不补播。
+- 本仓库不修改 App,不部署服务,不代表已完成真机通知投递、音频内容人工听审或系统播放验证。新用户 App 未带资源/渠道时,服务端参数本身不能使其播放这些语音。
+
+平台依据:[Apple 自定义通知声音](https://developer.apple.com/library/archive/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/SupportingNotificationsinYourApp.html)、[FCM 消息字段](https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages)、[Android 通知渠道](https://developer.android.com/develop/ui/views/notifications/channels)。

+ 10 - 0
specs/035-user-notification-voice/plan.md

@@ -1,5 +1,15 @@
 # 用户通知 eventScene Implementation Plan
 # 用户通知 eventScene Implementation Plan
 
 
+## 2026-10-26 原生语音追加计划(覆盖历史前台范围)
+
+1. 编写场景/语言、缓存与两端声音载荷及测试入口用例,复现缺少声音映射的失败。
+2. 新增 NativePushSoundResolver/NativePushSound,读取 /changeLanguages 的同源 Jedis 原始语言,使用白名单场景和资源名;缓存异常回退繁中并关闭连接。
+3. BusinessPushService 仅 native 分支解析声音;FCM/APNs 保留旧签名并新增声音参数重载。uni 和原业务 JSON 不变,骑手批量与商家默认声音不变。
+4. 测试 DTO 增加可选 language;生成 63 个共用 WAV 和 manifest,交付 App 渠道与资源说明。
+5. JDK 21 推送/闪送通知定向测试与 package、全部音频元数据和 diff 检查;不部署、不改 App,不把后端测试等同真机播放验收。
+
+不使用 MP3 直接充当 iOS 系统声音;采用 WAV 避免双份资源。Android 以每场景/语种固定版本渠道绑定声音,未来更换声音需双方协调渠道版本。
+
 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans for inline execution; use superpowers:subagent-driven-development only if the user selects that method. Steps use checkbox syntax for tracking.
 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans for inline execution; use superpowers:subagent-driven-development only if the user selects that method. Steps use checkbox syntax for tracking.
 
 
 **Goal:** 只在既有用户订单通知中新增 eventScene,供前端控制前台语音,并交付接入文档。
 **Goal:** 只在既有用户订单通知中新增 eventScene,供前端控制前台语音,并交付接入文档。

+ 14 - 0
specs/035-user-notification-voice/spec.md

@@ -1,5 +1,19 @@
 # Feature Specification: 用户业务通知语音提醒
 # Feature Specification: 用户业务通知语音提醒
 
 
+## 2026-10-26 追加需求:原生系统通知语音
+
+本节为最新确认范围,覆盖下文历史版本中“只前台、不改音频/系统声音”的原生通道限制;uniPush 仍保持原实现。后端与资源实现进度见 tasks,真机播放待 App 接入验收。
+
+- 原生用户通知在既有七个 eventScene 和两个闪送站点 eventType 中选择声音;不新增推送、不按 state/type 或文案猜场景。
+- FCM 发送 Android sound 资源名、channel_id 及前台 data.sound/data.channelId;APNs 发送 aps.sound 及前台顶层 sound。原 payload 与消息中心 body 保留不变。
+- 资源使用两端共用的 16 kHz 单声道 16-bit PCM WAV,9 场景 × 7 语种;原 MP3 不修改。简繁中文统一选择 zh-Hans/zh-Hant 源文件。
+- 业务声音读取现有 /changeLanguages 写入的原始语言(同一 Jedis DB 0、用户 ID 键),支持 zhHant/en/th/id/tl/ms 及约定别名;缺失、未知或读取失败一律繁中 zh-Hant。未知场景保留原默认声音。
+- 匿名原生测试入口追加可选 language,直接使用手填 Token,不查询账户,不改变业务推送模式;缺省语言按繁中。
+- App 负责随包资源、权限和 Android 8+ 声音渠道;前台不得系统音与手动播放重复,后台/锁屏交由系统处理,不保证绕过用户静音设置。
+- 不修改 uni、商家/骑手默认声音、数据库、业务通知时机或 App 仓库。接入、示例与验收见 [native-sound-integration.md](native-sound-integration.md)。
+
+验收:九场景与七语种对应清单、语言别名及繁中回退、两端实际发送载荷与测试入口、uni 隔离需通过定向测试;63 个音频需逐一验证编码/时长。真实前台/后台/锁屏语音须由 App 打包后另行真机验收。
+
 **Feature**: `035-user-notification-voice`
 **Feature**: `035-user-notification-voice`
 
 
 **Created**: 2026-10-09
 **Created**: 2026-10-09

+ 13 - 0
specs/035-user-notification-voice/tasks.md

@@ -1,5 +1,18 @@
 # Tasks: 用户通知 eventScene 与前端交接
 # Tasks: 用户通知 eventScene 与前端交接
 
 
+## 2026-10-26 原生前台/后台/锁屏语音追加
+
+**进度:实现中(本地验证已通过,未提交)。** 以下范围覆盖历史“只前台、不修改音频”的原生部分;uni 不变。
+
+- [x] T016 编写映射、繁中回退、载荷及测试入口测试;首次运行因缺少 NativePushSoundResolver 编译失败。
+- [x] T017 实现用户原生声音解析、同 /changeLanguages 的 Jedis 语言来源、连接释放、FCM/APNs 声音参数及测试入口可选 language;不改 uni。
+- [x] T018 转换 9 场景 × 7 语种为共用 WAV,生成资源/渠道 manifest;更新本 feature 及 033 的规格和接入文档。
+- [x] T019 JDK 21 推送/闪送通知定向测试与 package,逐个验证 WAV 编码/时长并进行差异检查;记录实际结果和未验证项。
+
+验证记录:JDK 21 执行 `mvn -q -pl ruoyi-admin -am -Dtest=*Push*Test,FlashDeliveryNotificationServiceTest -Dsurefire.failIfNoSpecifiedTests=false package` 退出 0;admin 132 项、system 9 项,共 141 项,0 失败/错误/跳过。覆盖场景/语言、缓存异常连接释放、原生测试/业务实际发送载荷及旧 uni/骑手批量兼容。63 个 WAV 与 manifest 全部一一匹配,逐文件 ffprobe 确认 pcm_s16le、16000 Hz、单声道、均小于 30 秒(约 1.87–3.72 秒);文件名、资源名、渠道 ID 校验通过。`git diff --check` 通过,无数据库/通道配置变更。本轮未提交、未执行完整回归、未部署或进行真实 FCM/APNs/手机系统声音验证,未做语音内容人工听审。
+
+App 仓库、部署、真实 FCM/APNs 到达、前台/后台/锁屏播放及内容人工听审不在本地后端验收范围。
+
 **Input:** [spec.md](spec.md)、[plan.md](plan.md)、[frontend-integration.md](frontend-integration.md)。
 **Input:** [spec.md](spec.md)、[plan.md](plan.md)、[frontend-integration.md](frontend-integration.md)。
 
 
 **进度:** 已验证(本期后端测试/构建和审查修复通过;完整回归 1 项既有失败另记)。本期只新增 eventScene;不修改 App、云函数、音频或数据库。任务按现有用户故事追踪,步骤/签名/断言以 plan 为准;控制器公共夹具集中于 UserOrderNotificationSceneTest,其余旧控制器测试继续回归。
 **进度:** 已验证(本期后端测试/构建和审查修复通过;完整回归 1 项既有失败另记)。本期只新增 eventScene;不修改 App、云函数、音频或数据库。任务按现有用户故事追踪,步骤/签名/断言以 plan 为准;控制器公共夹具集中于 UserOrderNotificationSceneTest,其余旧控制器测试继续回归。