feat(mobile,deploy): 接入安卓极光与华为通道并准备公网部署配置

客户端默认指向 jxd.jinniu.ink 的 HTTPS/WSS;登录后把 OpenIM userID 设为极光 alias。
华为 agconnect-services.json 不伪造、不入库。Master Secret 只从服务器文件注入。

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
编码工程师
2026-08-30 21:32:58 +08:00
co-authored by multica-agent
parent f35e1f03d5
commit f1fe4a3928
35 changed files with 687 additions and 64 deletions
+3
View File
@@ -44,6 +44,9 @@ app.*.map.json
/android/app/profile
/android/app/release
# 华为 AppGallery Connect 客户端配置(含 client_secret,禁止入库)
/android/app/agconnect-services.json
# 本机路径与构建缓存(不入库)
**/android/local.properties
**/android/.gradle/
+20 -8
View File
@@ -16,20 +16,32 @@ iOS 需要一台 Mac 搭配 Xcode 与开发者证书,另按苹果流程打包
已声明的平台权限(勿删):
- `android/app/src/main/AndroidManifest.xml``INTERNET``RECORD_AUDIO`(语音消息/通话)、`MODIFY_AUDIO_SETTINGS`、存储/相册读取(发文件、发图片);并开启了 `usesCleartextTraffic`(内网服务器暂用 http/ws 明文,开通外网改 HTTPS 后应移除)
- `android/app/src/main/AndroidManifest.xml``INTERNET``RECORD_AUDIO`(语音消息/通话)、`MODIFY_AUDIO_SETTINGS`、存储/相册读取(发文件、发图片)`POST_NOTIFICATIONS`(极光离线通知)。公网默认 HTTPS;内网联调 `--dart-define=USE_TLS=false` 时仍允许明文,故暂保留 `usesCleartextTraffic`
- `ios/Runner/Info.plist``NSMicrophoneUsageDescription`(语音消息与通话)、`NSPhotoLibraryUsageDescription`(发图片)、`NSAppTransportSecurity/NSAllowsArbitraryLoads`(同上,内网明文)。
- `android/build.gradle.kts` / `settings.gradle.kts` 把阿里云 Maven 镜像放在最前(本网络访问 maven.google.com 不稳定)。
- `android/build.gradle.kts` / `settings.gradle.kts` 把阿里云 Maven 镜像放在最前(本网络访问 maven.google.com 不稳定),并增加华为 Maven(厂商通道)
## 修改服务器地址
所有服务器地址集中在 `lib/config.dart`,改 `serverHost`(内网 IP/域名)和对应端口常量即可:
所有服务器地址集中在 `lib/config.dart`。默认公网:`jxd.jinniu.ink` + HTTPS/WSS。
| 常量 | 用途 | 默认值 |
内网联调不要改文件,打包时覆盖:
```bash
flutter build apk --release \
--dart-define=SERVER_HOST=192.168.200.11 \
--dart-define=USE_TLS=false
```
仅覆盖登录服务:`--dart-define=AUTH_HOST=127.0.0.1`
| 常量 | 用途 | 公网默认 |
| --- | --- | --- |
| `apiAddr` | OpenIM API | `http://192.168.200.11:10002` |
| `wsAddr` | OpenIM WebSocket | `ws://192.168.200.11:10001` |
| `livekitUrl` | LiveKit 语音通话 | `ws://192.168.200.11:17880`(注意已重映射,非默认 7880 |
| `authApiBase` | 公司账号登录后端 | `http://192.168.200.11:10010` |
| `apiAddr` | OpenIM API | `https://jxd.jinniu.ink` |
| `wsAddr` | OpenIM WebSocket | `wss://jxd.jinniu.ink/msg_gateway` |
| `livekitUrl` | LiveKit 语音通话 | `wss://jxd.jinniu.ink/livekit` |
| `authApiBase` | 公司账号登录后端 | `https://jxd.jinniu.ink/account` |
华为厂商通道还缺 `android/app/agconnect-services.json`,取得方式见同目录 `README-agconnect.md`。不要把该文件提交进 Git。
## 登录接口契约(后端:任务 B-60 account-service,已上线)
+21
View File
@@ -0,0 +1,21 @@
# 华为厂商通道配置文件(缺口)
华为通道客户端其余依赖已接入。**唯一还缺的文件**是:
`mobile/android/app/agconnect-services.json`
## 从哪里取得
1. 打开华为 [AppGallery Connect](https://developer.huawei.com/consumer/cn/service/josp/agc/index.html)
2. 选择与包名 `cn.solsum.jinxunda` 对应的应用
3. 项目设置 → 应用 → 下载 `agconnect-services.json`
4. 把文件放到本目录(与 `build.gradle.kts` 同级)
5. **不要提交进 Git**(文件含华为 client_secret)。仓库已忽略该路径。
没有这份文件时:
- 安卓包仍可构建(AGConnect 插件按文件是否存在条件启用)
- 极光自己的通道仍可初始化
- **华为真机的厂商通道无法接通**,杀进程后的到达率会明显下降
本阶段不伪造、不提交该文件。
+12 -4
View File
@@ -4,6 +4,13 @@ plugins {
id("dev.flutter.flutter-gradle-plugin")
}
// 华为 agconnect-services.json 到位后才启用 AGConnect 插件,避免缺文件时整包编不过。
// 文件从华为 AppGallery Connect 下载,放到本目录,不要提交进 Git。
val agconnectServices = file("agconnect-services.json")
if (agconnectServices.exists()) {
apply(plugin = "com.huawei.agconnect")
}
android {
namespace = "cn.solsum.jinxunda"
compileSdk = flutter.compileSdkVersion
@@ -15,14 +22,15 @@ android {
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "cn.solsum.jinxunda"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
// 极光厂商通道插件要求 minSdk 24
minSdk = 24
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
manifestPlaceholders["JPUSH_PKGNAME"] = "cn.solsum.jinxunda"
manifestPlaceholders["JPUSH_APPKEY"] = "95fc2352648a7d4568ac8d72"
manifestPlaceholders["JPUSH_CHANNEL"] = "developer-default"
}
// 压缩打包 .so(默认未压缩存储,OpenIM/WebRTC 的 native 库让包多占近一倍体积);
@@ -4,6 +4,9 @@
<!-- OpenIM 插件注册网络状态监听必须要这个权限;插件自身 manifest 漏了声明,
缺它 release 包会 SecurityException 导致插件注册失败、SDK 全部调用无响应 -->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<!-- Android 13+ 通知栏权限(极光离线推送) -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.VIBRATE"/>
<!-- 语音消息录音与语音通话 -->
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
@@ -33,6 +36,10 @@
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<!-- 关闭 Flutter 自动深链接,避免厂商通道点击拉起白屏 -->
<meta-data
android:name="flutter_deeplinking_enabled"
android:value="false" />
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
@@ -2,4 +2,8 @@ package cn.solsum.jinxunda
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()
class MainActivity : FlutterActivity() {
// Flutter 3.29+ 默认把启动 Intent 当深链接解析。华为等厂商通道点击拉起时
// intent 带 n_extra,路由表没有对应页会白屏。本应用不用官方深链接。
override fun shouldHandleDeeplinking(): Boolean = false
}
+1
View File
@@ -4,6 +4,7 @@ allprojects {
maven("https://maven.aliyun.com/repository/google")
maven("https://maven.aliyun.com/repository/central")
maven("https://maven.aliyun.com/repository/public")
maven("https://developer.huawei.com/repo/")
google()
mavenCentral()
}
+2
View File
@@ -15,6 +15,7 @@ pluginManagement {
maven("https://maven.aliyun.com/repository/google")
maven("https://maven.aliyun.com/repository/central")
maven("https://maven.aliyun.com/repository/gradle-plugin")
maven("https://developer.huawei.com/repo/")
google()
mavenCentral()
gradlePluginPortal()
@@ -25,6 +26,7 @@ plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "9.0.1" apply false
id("org.jetbrains.kotlin.android") version "2.3.20" apply false
id("com.huawei.agconnect") version "1.9.1.301" apply false
}
include(":app")
+37 -14
View File
@@ -1,14 +1,24 @@
/// 服务器地址集中配置。
///
/// 服务器换了地址只需要改这一个文件:
/// 1. 改下面的 [serverHost] 为新的服务器内网 IP(或域名);
/// 2. 如果端口也被重新映射,改对应端口常量即可。
/// 默认指向公网测试域名 `jxd.jinniu.ink`HTTPS / WSS)。
/// 内网联调不要改本文件,打包时覆盖:
/// `--dart-define=SERVER_HOST=192.168.200.11 --dart-define=USE_TLS=false`
/// 仅覆盖登录服务:`--dart-define=AUTH_HOST=127.0.0.1`
library;
/// 服务器内网地址(不带协议、不带端口)
const String serverHost = '192.168.200.11';
/// 公网测试域名(不带协议、不带端口)
const String defaultPublicHost = 'jxd.jinniu.ink';
/// OpenIM API 端口(服务端 docker 映射,默认 10002
/// 服务器地址(不带协议、不带端口
const String serverHost = String.fromEnvironment(
'SERVER_HOST',
defaultValue: defaultPublicHost,
);
/// 是否走 HTTPS/WSS(公网默认 true;内网联调传 false)
const bool useTls = bool.fromEnvironment('USE_TLS', defaultValue: true);
/// OpenIM API 端口(服务端 docker 映射,默认 10002TLS 时由反向代理走 443
const int apiPort = 10002;
/// OpenIM WebSocket 端口(默认 10001
@@ -20,17 +30,30 @@ const int livekitPort = 17880;
/// 公司账号登录后端端口(B-60 account-serverdocker 默认映射 10010
const int authApiPort = 10010;
/// OpenIM API 地址,例:http://192.168.200.11:10002
const String apiAddr = 'http://$serverHost:$apiPort';
const String _authHostOverride = String.fromEnvironment('AUTH_HOST', defaultValue: '');
/// OpenIM 消息长连接地址,例:ws://192.168.200.11:10001
const String wsAddr = 'ws://$serverHost:$wsPort';
/// OpenIM API 地址
String get apiAddr => useTls ? 'https://$serverHost' : 'http://$serverHost:$apiPort';
/// LiveKit 连接地址,例:ws://192.168.200.11:17880
const String livekitUrl = 'ws://$serverHost:$livekitPort';
/// OpenIM 消息长连接地址
String get wsAddr => useTls ? 'wss://$serverHost/msg_gateway' : 'ws://$serverHost:$wsPort';
/// 公司账号登录后端地址,例:http://192.168.200.11:10010
/// LiveKit 连接地址
String get livekitUrl => useTls ? 'wss://$serverHost/livekit' : 'ws://$serverHost:$livekitPort';
/// 公司账号登录后端地址
///
/// 仅本地联调用:`--dart-define=AUTH_HOST=127.0.0.1` 可覆盖登录服务地址(OpenIM/LiveKit
/// 仍走 [serverHost]),正式打包不带该参数则与 [serverHost] 一致。
const String authApiBase = 'http://${String.fromEnvironment('AUTH_HOST', defaultValue: serverHost)}:$authApiPort';
String get authApiBase {
if (_authHostOverride.isNotEmpty) {
return 'http://$_authHostOverride:$authApiPort';
}
return useTls ? 'https://$serverHost/account' : 'http://$serverHost:$authApiPort';
}
/// 极光 AppKey(可进客户端;Master Secret 禁止出现在客户端或仓库)
const String jpushAppKey = '95fc2352648a7d4568ac8d72';
/// 登录页自检:探测消息长连接入口(TLS 走反向代理路径,明文走宿主端口)
String get wsProbeUrl => useTls ? 'https://$serverHost/msg_gateway' : 'http://$serverHost:$wsPort/';
+4
View File
@@ -7,10 +7,12 @@ import 'screens/home_screen.dart';
import 'screens/login_screen.dart';
import 'services/call_service.dart';
import 'services/im_service.dart';
import 'services/push_service.dart';
import 'theme.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
PushService.instance.init();
runApp(const ChanglianApp());
}
@@ -27,6 +29,7 @@ class _ChanglianAppState extends State<ChanglianApp> {
super.initState();
// 被踢下线 / token 失效:清凭证、回登录页
IMService.instance.onForceLogout = () async {
await PushService.instance.unbindUser();
await LoginScreen.clearCredential();
navigatorKey.currentState?.pushAndRemoveUntil(
MaterialPageRoute(builder: (_) => const LoginScreen()),
@@ -90,6 +93,7 @@ class _SplashGateState extends State<SplashGate> {
await im.init().timeout(const Duration(seconds: 20));
await im.login(userID: credential.$1, token: credential.$2).timeout(const Duration(seconds: 20));
CallService.instance.start();
await PushService.instance.bindUser(credential.$1);
if (!mounted) return;
Navigator.of(context).pushReplacement(MaterialPageRoute(builder: (_) => const HomeScreen()));
return;
+5 -2
View File
@@ -6,6 +6,7 @@ import '../config.dart';
import '../services/auth_api.dart';
import '../services/call_service.dart';
import '../services/im_service.dart';
import '../services/push_service.dart';
import '../theme.dart';
import 'home_screen.dart';
@@ -92,7 +93,9 @@ class _LoginScreenState extends State<LoginScreen> {
);
// 3. 启动通话信令监听
CallService.instance.start();
// 4. 存凭证用于下次自动登录
// 4. 极光 alias = OpenIM userID,供服务端离线推送
await PushService.instance.bindUser(result.userID);
// 5. 存凭证用于下次自动登录
await LoginScreen.saveCredential(result.userID, result.token, result.nickname);
if (!mounted) return;
Navigator.of(context).pushReplacement(
@@ -150,7 +153,7 @@ class _LoginScreenState extends State<LoginScreen> {
final results = await Future.wait([
probe('账号服务', '$authApiBase/api/health'),
probe('消息接口', '$apiAddr/'),
probe('消息长连接', 'http://$serverHost:$wsPort/'),
probe('消息长连接', wsProbeUrl),
]);
return '自检:${results.join('')}';
}
+2
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_openim_sdk/flutter_openim_sdk.dart';
import '../services/im_service.dart';
import '../services/push_service.dart';
import '../theme.dart';
import '../widgets/avatar.dart';
import 'login_screen.dart';
@@ -166,6 +167,7 @@ class _MineScreenState extends State<MineScreen> {
onPressed: () async {
Navigator.of(ctx).pop();
await LoginScreen.clearCredential();
await PushService.instance.unbindUser();
await IMService.instance.logout();
if (!mounted) return;
Navigator.of(context).pushAndRemoveUntil(
@@ -50,7 +50,12 @@ class MessageSendService {
message: message,
userID: isGroup ? null : peer,
groupID: isGroup ? groupID : null,
offlinePushInfo: OfflinePushInfo(),
offlinePushInfo: OfflinePushInfo(
title: '畅联',
desc: '你收到一条新消息',
iOSBadgeCount: true,
iOSPushSound: '+1',
),
);
return sent;
} catch (e) {
+103
View File
@@ -0,0 +1,103 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:jpush_flutter/jpush_flutter.dart';
import 'package:jpush_flutter/jpush_interface.dart';
import 'package:permission_handler/permission_handler.dart';
import '../config.dart';
import '../nav.dart';
import '../screens/home_screen.dart';
import 'im_service.dart';
/// 极光推送封装:初始化、通知权限、Registration ID、把 OpenIM userID 设为 alias。
///
/// OpenIM 服务端 JPUSH 离线推送按 alias=userID 下发,因此登录后必须 setAlias。
/// 本阶段只接安卓;iOS 不调用 setup。
class PushService {
PushService._();
static final PushService instance = PushService._();
final JPushFlutterInterface _jpush = JPush.newJPush();
bool _ready = false;
String? _boundAlias;
Future<void> init() async {
if (_ready) return;
if (kIsWeb || !Platform.isAndroid) return;
try {
_jpush.addEventHandler(
onReceiveNotification: (message) async {},
onOpenNotification: (message) async {
_onNotificationClick();
},
onReceiveMessage: (message) async {},
onReceiveNotificationAuthorization: (message) async {},
onConnected: (message) async {},
onCommandResult: (message) async {},
);
_jpush.setAuth(enable: true);
_jpush.setup(
appKey: jpushAppKey,
channel: 'developer-default',
production: true,
debug: false,
);
_jpush.applyPushAuthority(
const NotificationSettingsIOS(sound: true, alert: true, badge: true),
);
await _requestAndroidNotificationPermission();
_ready = true;
await _jpush.getRegistrationID();
} catch (_) {
// 推送失败不能挡住登录和聊天
}
}
Future<void> bindUser(String userID) async {
if (userID.isEmpty) return;
await init();
if (!_ready) return;
try {
await _jpush.setAlias(userID);
_boundAlias = userID;
} catch (_) {
// alias 失败时下次登录再试
}
}
Future<void> unbindUser() async {
if (!_ready) return;
try {
await _jpush.deleteAlias();
} catch (_) {
// 本地照常清理
}
_boundAlias = null;
}
String? get boundAlias => _boundAlias;
Future<void> _requestAndroidNotificationPermission() async {
try {
final status = await Permission.notification.status;
if (!status.isGranted) {
await Permission.notification.request();
}
} catch (_) {
// 权限申请失败不阻断
}
}
void _onNotificationClick() {
final nav = navigatorKey.currentState;
if (nav == null) return;
if (!IMService.instance.loggedIn) return;
nav.pushAndRemoveUntil(
MaterialPageRoute(builder: (_) => const HomeScreen()),
(route) => false,
);
}
}
+11 -1
View File
@@ -1,7 +1,7 @@
name: changlian
description: 畅联 —— 公司内部通讯 App(手机端)
publish_to: "none"
version: 1.0.6+7
version: 1.0.7+8
environment:
sdk: ">=3.6.0 <4.0.0"
@@ -37,6 +37,16 @@ dependencies:
open_filex: 4.7.0
# 通话房间号
uuid: 4.5.2
# 极光推送官方 Flutter 插件(MIT);厂商通道另见 jpush_flutter_android
jpush_flutter: 3.5.6
jpush_flutter_android: 1.0.2
# 极光安卓厂商通道:本阶段只开华为。AppKey 可进客户端;其余厂商密钥不要填。
jpush_android:
app_key: 95fc2352648a7d4568ac8d72
channel: developer-default
huawei:
enable: true
dev_dependencies:
flutter_test:
+16
View File
@@ -0,0 +1,16 @@
import 'package:changlian/config.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
test('默认公网地址走 HTTPS/WSS 且不含测试 IP', () {
expect(serverHost, 'jxd.jinniu.ink');
expect(useTls, isTrue);
expect(apiAddr, 'https://jxd.jinniu.ink');
expect(wsAddr, 'wss://jxd.jinniu.ink/msg_gateway');
expect(livekitUrl, 'wss://jxd.jinniu.ink/livekit');
expect(authApiBase, 'https://jxd.jinniu.ink/account');
expect(apiAddr, isNot(contains('154.37')));
expect(wsAddr, isNot(contains('154.37')));
expect(jpushAppKey, '95fc2352648a7d4568ac8d72');
});
}