Compare commits

..
Author SHA1 Message Date
716e43cea3 docs: 记录 B-272 Android 权限、通话文案与位置清理及构建证据
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-21 10:01:50 +08:00
d6fafe7f95 fix(mobile-next): 移除悬浮窗申请与位置发送,通话文案改为语音通话
同步完成后不再索取 SYSTEM_ALERT_WINDOW,去掉旧浮窗服务声明和定位权限/发送入口;历史位置消息仍只读展示。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-21 10:01:49 +08:00
21 changed files with 247 additions and 479 deletions
@@ -0,0 +1,104 @@
# B-272 Android 权限、通话文案与位置功能清理
分支:`agent/agent/d488336b`
施工基线:B-269 建议的最新已交付 Android HEAD `092039bc1a55c3ddf853ac39d04f6171e7cb5a4c``review-latest` / `origin/agent/agent/2b5c0459`
方案依据:B-269 附件 `b269-architecture-plan.md` 的 P1-1 / P1-2
本卡只改 `mobile-next/` 与本验收记录,不改旧 `mobile/`、PC、现网服务,不发布 APK,不覆盖 Gitea Release。
**结论:Android release 构建通过。合并清单已去掉悬浮窗与定位权限;用户可见通话文案统一为「语音通话」;位置发送入口与定位依赖已移除,历史位置消息仍只读解析。真机登录、来电、历史位置点开未测,不得写成运行验收通过。**
## 权限前后对比(release APK 合并清单)
| 权限 / 组件 | 改前 | 改后 |
|---|---|---|
| `SYSTEM_ALERT_WINDOW` | 声明,同步完成后无条件申请 | `tools:node="remove"`,无申请调用 |
| 旧 `CallService``flutter_openim_live_alert` | 在 app Manifest 声明 | 已删除声明;本地插件目录保留,未物理删除 |
| `ACCESS_COARSE/FINE/BACKGROUND_LOCATION``ACCESS_LOCATION_EXTRA_COMMANDS` | 声明,工具箱发位置会请求 | `tools:node="remove"`;去掉 `geolocator` |
| `FOREGROUND_SERVICE` / `ForegroundService` / `IsolateHolderService` | 保留 | 保留,未改通话中前台通知链路 |
| 定位发送页 `ChatWebViewMap` | 工具箱可进选点并 `createLocationMessage` | 已删除发送页与发送方法 |
合并 APK `aapt dump permissions` 已确认不含 `SYSTEM_ALERT_WINDOW``ACCESS_*LOCATION`
## 文案
只改通话可见文本,未改协议字段、`CallType`、消息类型或 SDK 枚举。相册「视频」、视频消息、视频会议字样保留。
| 入口 | 改前 | 改后 |
|---|---|---|
| 工具箱 `toolboxCall` | 视频通话 | 语音通话 |
| 资料页 `audioAndVideoCall` | 音视频通话 | 语音通话,图标改用 `ImageRes.callVoice` |
| 来电/邀请/群通话提示中的「视频通话」 | 视频通话 | 语音通话 |
| 通话选择面板 | 语音 + 视频两项 | 仅语音一项 |
## 位置只读
- 聊天「更多」与备用输入条不再出现位置发送。
- 历史 `MessageType.location` 仍由 `ChatLocationView` 展示;点击走只读 `MapView`,不进入发送页。
- 描述 JSON 损坏或缺字段时显示「位置」占位,不抛异常、不渲染空白卡片。
## 提交主题(可逐项 `git revert`
从新到旧回退:先本验收记录,再代码提交。
| 主题 | SHA | 文件 |
|---|---|---|
| 本验收记录 | 本文件当次提交 | `docs/acceptance/b272-android-permission-copy-location.md` |
| 权限/文案/位置清理 | 见代码提交 | `mobile-next/` 见 git log |
回退示例(新到旧):
```
git revert <本验收提交>
git revert <代码提交>
```
回退不影响 `mobile/``account-service/``config/``scripts/`、现网服务、PC、已发布安装包。旧悬浮窗插件目录 `mobile-next/local_plugin/flutter_openim_live_alert` 仍在仓库,仅不再接入。
## 环境
- Flutter 3.24.5 / Dart 3.5.4`C:\flutter324`
- Temurin JDK 17.0.20+8`C:\devtools\jdk17`
- Android SDK`C:\devtools\android-sdk`build-tools 34.0.0compileSdk 34
- `org.gradle.jvmargs=-Xmx4096M`
## 检查与构建
```
cd mobile-next
flutter test test/company/p1_cleanup_test.dart test/company/call_session_guard_test.dart test/company/rtc_token_mapper_test.dart test/company/call_visual_rework_test.dart test/company/user_full_info_mapper_test.dart
flutter analyze lib/core/controller/im_controller.dart lib/pages/chat/chat_logic.dart lib/pages/chat/chat_view.dart lib/company openim_common/lib/src/utils/permissions.dart openim_common/lib/src/widgets/chat/chat_toolbox.dart openim_common/lib/src/widgets/chat/chat_location_view.dart openim_common/lib/src/widgets/rich_text_input_box.dart openim_common/lib/src/widgets/button.dart openim_common/lib/src/widgets/views.dart openim_live/lib/src/live_controller.dart
flutter build apk --release
```
- 公司相关单测 **21 项通过**(含本卡 2 项:文案不含「视频通话/音视频通话」、位置描述损坏可解析)。
- 定点 `flutter analyze`**无 error**。info/warning 为存量(Token const、废弃 API、Kotlin metadata 1.8 vs 1.6 等同 B-225/B-261)。
- Android release 构建成功。上游告警同前:Flutter Gradle apply-script 弃用;部分依赖 Kotlin metadata 1.8.0 vs 工程预期 1.6.0。
| 产物 | 版本 | 包名 | 大小 | SHA256 |
|---|---|---|---|---|
| Android release APK | 3.8.3+180 | `io.openim.flutter.demo` | 53,095,552 B | `A37BA10EB94D43F0B113130CE90EEE554FD9F3EC00C7C7A9D025CA1059C0E76C` |
路径:`mobile-next/build/app/outputs/flutter-apk/app-release.apk`(APK 内嵌时间戳,哈希不可位级复现)。label `F-DEMO`,仅 `arm64-v8a`minSdk 24 / targetSdk 33 / compileSdk 34。未发布、未覆盖 Gitea 现有 Release。
## 已测 / 未测
已测:
- 施工基线为 `092039b`,不是 `origin/main` 的旧 `fa8584a`
- 中文通话文案静态扫描不再含「视频通话」「音视频通话」;「视频」消息字样仍在
- 位置描述合法 JSON / 损坏 JSON / 空值解析不抛异常
- 通话状态机、换票、视觉 Token、我的信息映射既有单测回归
- release APK 合并清单无悬浮窗、无定位权限;无旧 `CallService`;前台通知服务仍在
- Android release 构建通过
未测(不得写成已通过):
- 公司内网真机冷启动、登录、同步完成后是否还弹出悬浮窗设置
- 真机系统权限页是否仍列出「显示在其他应用上层」或定位
- 语音来电、接听、通话中最小化、后台返回
- 历史位置消息在真机会话中的展示与点击
- 文字/图片/文件/语音消息与相册视频发送的真机回归
## 边界
未改:旧 `mobile/`、PC、`account-service/`、docker、端口、账号、数据库、LiveKit 配置、DNS/证书/推送、品牌换标、APK 发布。
@@ -12,7 +12,7 @@
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" tools:node="remove" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
@@ -26,14 +26,14 @@
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_LOCATION_EXTRA_COMMANDS" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" tools:node="remove" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" tools:node="remove" />
<uses-permission android:name="android.permission.ACCESS_LOCATION_EXTRA_COMMANDS" tools:node="remove" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" tools:node="remove" />
<uses-permission android:name="android.permission.ACCESS_NOTIFICATION_POLICY" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
<uses-permission android:name="android.permission.CAPTURE_VIDEO_OUTPUT" />
@@ -130,10 +130,6 @@
android:exported="false"
android:stopWithTask="false" />
<service
android:name="io.openim.live.alert.flutter_openim_live_alert.services.CallService"
android:exported="false" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileProvider"
@@ -1,12 +1,10 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_openim_sdk/flutter_openim_sdk.dart';
import 'package:get/get.dart';
import 'package:openim_common/openim_common.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:openim_live/openim_live.dart';
import '../../company/feature_flags.dart';
@@ -236,9 +234,6 @@ class IMController extends GetxController with IMCallback, OpenIMLive {
},
onSyncServerFinish: (reInstall) {
imSdkStatus(IMSdkStatus.syncEnded, reInstall: reInstall ?? false);
if (Platform.isAndroid) {
Permissions.request([Permission.systemAlertWindow]);
}
},
onSyncServerStart: (reInstall) {
imSdkStatus(IMSdkStatus.syncStart, reInstall: reInstall ?? false);
@@ -446,17 +446,6 @@ class ChatLogic extends SuperController {
_sendMessage(message);
}
void sendLocation({
required dynamic location,
}) async {
var message = await OpenIM.iMManager.messageManager.createLocationMessage(
latitude: location['latitude'],
longitude: location['longitude'],
description: location['description'],
);
_sendMessage(message);
}
sendForwardRemarkMsg(
String content, {
String? userId,
@@ -714,18 +703,6 @@ class ChatLogic extends SuperController {
forceCloseToolbox.addSafely(true);
}
void onTapLocation() async {
var location = await Get.to(
const ChatWebViewMap(host: Config.locationHost, webKey: Config.webKey, webServerKey: Config.webServerKey),
transition: Transition.cupertino,
popGesture: true,
);
if (null != location) {
Logger.print(location);
sendLocation(location: location);
}
}
void onTapAlbum() async {
final List<AssetEntity>? assets = await AssetPicker.pickAssets(Get.context!,
pickerConfig: AssetPickerConfig(
@@ -247,7 +247,6 @@ class ChatPage extends StatelessWidget {
onTapCall: FeatureFlags.livekitCall && logic.isSingleChat ? logic.call : null,
onTapCard: logic.onTapCarte,
onTapFile: logic.onTapFile,
onTapLocation: logic.onTapLocation,
),
voiceRecordBar: bar,
emojiView: ChatEmojiView(
@@ -76,7 +76,6 @@ export 'src/widgets/chat/chat_voice_record_bar.dart';
export 'src/widgets/chat/chat_voice_record_layout.dart';
export 'src/widgets/chat/chat_voice_record_view.dart';
export 'src/widgets/chat/chat_voice_view.dart';
export 'src/widgets/chat/chat_webview_map.dart';
export 'src/widgets/chat/new_message_indicator.dart';
export 'src/widgets/chat/water_mark_view.dart';
export 'src/widgets/custom_pop_up_menu.dart';
@@ -109,7 +109,7 @@ const Map<String, String> en_US = {
'rejectCall': 'Reject',
'acceptCall': 'Accept',
"callVoice": "Voice Call",
"callVideo": "Video Call",
"callVideo": "Voice Call",
"sentSuccessfully": "Sent Successfully",
"copySuccessfully": "Copied Successfully",
"day": "day",
@@ -120,7 +120,7 @@ const Map<String, String> en_US = {
"cancel": "Cancel",
"determine": "OK",
"toolboxAlbum": "Album",
"toolboxCall": "Video Call",
"toolboxCall": "Voice Call",
"toolboxCamera": "Camera",
"toolboxCard": "Card",
"toolboxFile": "File",
@@ -156,7 +156,7 @@ const Map<String, String> en_US = {
'nMessage': '%s New Messages',
'plsSelectLocation': 'Please select a location',
'groupAudioCallHint': '%s people in a voice call',
'groupVideoCallHint': '%s people in a video call',
'groupVideoCallHint': '%s people in a voice call',
'reEdit': 'Re-edit',
'playSpeed': 'Playback Speed',
'download': 'Download',
@@ -224,7 +224,7 @@ const Map<String, String> en_US = {
'position': 'Position',
'personalInfo': 'Personal Info',
'viewDynamics': 'View Dynamics',
'audioAndVideoCall': 'Call',
'audioAndVideoCall': 'Voice Call',
'sendMessage': 'Message',
'avatar': 'Avatar',
'name': 'Name',
@@ -436,7 +436,7 @@ const Map<String, String> en_US = {
"waitingVoiceCallHint": "Waiting for the other party to accept the invitation...",
"invitedVoiceCallHint": "Inviting you to a voice call",
"waitingVideoCallHint": "Waiting for the other party to accept the invitation",
"invitedVideoCallHint": "Inviting you for a video call...",
"invitedVideoCallHint": "Inviting you to a voice call",
"waitingToAnswer": "Waiting to answer",
"invitedYouToCall": "Invited you to a call",
"calling": "Calling...",
@@ -444,7 +444,7 @@ const Map<String, String> en_US = {
'busyVideoCallHint': 'The other party is busy and cannot receive the invitation',
'inviterBusyVideoCallHint': 'You are in another call and cannot send an invitation',
"whoInvitedVoiceCallHint": "%s invited you for a voice call",
"whoInvitedVideoCallHint": "%s invited you for a video call",
"whoInvitedVideoCallHint": "%s invited you for a voice call",
'plsInputMeetingSubject': 'Please enter meeting subject',
'meetingStartTime': 'Start Time',
'meetingDuration': 'Meeting Duration',
@@ -109,7 +109,7 @@ const Map<String, String> zh_CN = {
'rejectCall': '拒绝',
'acceptCall': '同意',
"callVoice": "语音通话",
"callVideo": "视频通话",
"callVideo": "语音通话",
"sentSuccessfully": "发送成功",
"copySuccessfully": "复制成功",
"day": "",
@@ -120,7 +120,7 @@ const Map<String, String> zh_CN = {
"cancel": "取消",
"determine": "确定",
"toolboxAlbum": "相册",
"toolboxCall": "视频通话",
"toolboxCall": "语音通话",
"toolboxCamera": "拍摄",
"toolboxCard": "名片",
"toolboxFile": "文件",
@@ -156,7 +156,7 @@ const Map<String, String> zh_CN = {
'nMessage': '%s条新消息',
'plsSelectLocation': '请选择一个位置',
'groupAudioCallHint': '%s人正在语音通话',
'groupVideoCallHint': '%s人正在视频通话',
'groupVideoCallHint': '%s人正在语音通话',
'reEdit': '重新编辑',
'playSpeed': '播放速度',
'download': '下载',
@@ -224,7 +224,7 @@ const Map<String, String> zh_CN = {
'position': '职位',
'personalInfo': '个人资料',
'viewDynamics': '查看动态',
'audioAndVideoCall': '视频通话',
'audioAndVideoCall': '音通话',
'sendMessage': '发消息',
'avatar': '头像',
'name': '姓名',
@@ -438,7 +438,7 @@ const Map<String, String> zh_CN = {
"waitingVoiceCallHint": "正在等待对方接受邀请…",
"invitedVoiceCallHint": "邀请你语音通话",
"waitingVideoCallHint": "正在等待对方接受邀请",
"invitedVideoCallHint": "邀请你进行视频通话",
"invitedVideoCallHint": "邀请你语音通话",
"waitingToAnswer": "等待接听",
"invitedYouToCall": "邀请你通话",
"calling": "通话中",
@@ -446,7 +446,7 @@ const Map<String, String> zh_CN = {
'busyVideoCallHint': '对方正忙,无法接收邀请',
'inviterBusyVideoCallHint': '你在其它通话中,无法发起邀请',
"whoInvitedVoiceCallHint": "%s邀请你进行语音通话",
"whoInvitedVideoCallHint": "%s邀请你进行视频通话",
"whoInvitedVideoCallHint": "%s邀请你进行语音通话",
'plsInputMeetingSubject': '请输入会议主题',
'meetingStartTime': '开始时间',
'meetingDuration': '会议时长',
@@ -10,10 +10,6 @@ import 'package:sprintf/sprintf.dart';
class Permissions {
Permissions._();
static Future<bool> checkSystemAlertWindow() async {
return Permission.systemAlertWindow.isGranted;
}
static Future<bool> checkStorage() async {
return await Permission.storage.isGranted;
}
@@ -66,15 +62,6 @@ class Permissions {
}
}
static void location(Function()? onGranted) async {
if (await Permission.location.request().isGranted) {
onGranted?.call();
}
if (await Permission.location.isPermanentlyDenied || await Permission.location.isDenied) {
_showPermissionDeniedDialog(Permission.location.title);
}
}
static void speech(Function()? onGranted) async {
if (await Permission.speech.request().isGranted) {
onGranted?.call();
@@ -850,8 +850,7 @@ class IMUtils {
var customData = map['data'];
switch (customType) {
case CustomMessageType.call:
var type = map['data']['type'];
content = '[${type == 'video' ? StrRes.callVideo : StrRes.callVoice}]';
content = '[${StrRes.callVoice}]';
break;
case CustomMessageType.emoji:
content = '[${StrRes.emoji}]';
@@ -1326,15 +1325,19 @@ class IMUtils {
}
static void previewLocation(Message message) {
var location = message.locationElem;
Map detail = json.decode(location!.description!);
final location = message.locationElem;
if (location == null) {
IMViews.showToast(StrRes.location);
return;
}
final detail = LocationPreviewData.parse(location.description);
Logger.print('previewLocation ${location.latitude} ${location.longitude}');
Get.to(
() => MapView(
latitude: location.latitude!,
longitude: location.longitude!,
address1: detail['name'],
address2: detail['addr'],
latitude: location.latitude ?? 0,
longitude: location.longitude ?? 0,
address1: detail.name,
address2: detail.addr,
),
transition: Transition.cupertino,
popGesture: true,
@@ -78,7 +78,7 @@ class ImageTextButton extends StatelessWidget {
final Function()? onTap;
ImageTextButton.call({super.key, this.onTap})
: icon = ImageRes.audioAndVideoCall,
: icon = ImageRes.callVoice,
text = StrRes.audioAndVideoCall,
color = Styles.c_FFFFFF,
textStyle = null,
@@ -276,9 +276,9 @@ class _ChatItemViewState extends State<ChatItemView> {
} else if (_message.isLocationType) {
final location = _message.locationElem;
child = ChatLocationView(
description: location!.description!,
latitude: location.latitude!,
longitude: location.longitude!,
description: location?.description ?? '',
latitude: location?.latitude ?? 0,
longitude: location?.longitude ?? 0,
);
} else if (_message.isCardType) {
child = ChatCarteView(cardElem: _message.cardElem!);
@@ -4,63 +4,96 @@ import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:openim_common/openim_common.dart';
class LocationPreviewData {
const LocationPreviewData({
required this.name,
required this.addr,
required this.url,
});
final String name;
final String addr;
final String url;
static LocationPreviewData parse(String? description) {
var name = '';
var addr = '';
var url = '';
try {
final raw = description?.trim() ?? '';
if (raw.isNotEmpty) {
final decoded = json.decode(raw);
if (decoded is Map) {
url = '${decoded['url'] ?? ''}';
name = '${decoded['name'] ?? ''}';
addr = '${decoded['addr'] ?? ''}';
}
}
} catch (_) {}
if (name.trim().isEmpty) {
name = StrRes.location;
}
return LocationPreviewData(name: name, addr: addr, url: url);
}
}
class ChatLocationView extends StatelessWidget {
const ChatLocationView({
Key? key,
super.key,
required this.description,
required this.latitude,
required this.longitude,
}) : super(key: key);
});
final String description;
final double latitude;
final double longitude;
final _decoder = const JsonDecoder();
@override
Widget build(BuildContext context) {
try {
final map = _decoder.convert(description);
String url = map['url'] ?? '';
String name = map['name'] ?? '';
String addr = map['addr'] ?? '';
return Container(
width: locationWidth,
height: 130.h,
decoration: BoxDecoration(
color: Styles.c_FFFFFF,
border: Border.all(color: Styles.c_E8EAEF, width: 1),
borderRadius: BorderRadius.circular(6.r),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
4.verticalSpace,
Padding(
padding: EdgeInsets.symmetric(horizontal: 4.w),
child: name.toText
..style = Styles.ts_0C1C33_14sp
..maxLines = 1
..overflow = TextOverflow.ellipsis,
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 4.w),
child: addr.toText
..style = Styles.ts_8E9AB0_12sp
..maxLines = 1
..overflow = TextOverflow.ellipsis,
),
2.verticalSpace,
Expanded(
child: ImageUtil.networkImage(
url: url,
width: locationWidth,
fit: BoxFit.cover,
),
),
],
),
);
} catch (e) {}
return Container();
final data = LocationPreviewData.parse(description);
return Container(
width: locationWidth,
height: 130.h,
decoration: BoxDecoration(
color: Styles.c_FFFFFF,
border: Border.all(color: Styles.c_E8EAEF, width: 1),
borderRadius: BorderRadius.circular(6.r),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
4.verticalSpace,
Padding(
padding: EdgeInsets.symmetric(horizontal: 4.w),
child: data.name.toText
..style = Styles.ts_0C1C33_14sp
..maxLines = 1
..overflow = TextOverflow.ellipsis,
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 4.w),
child: (data.addr.isEmpty ? StrRes.location : data.addr).toText
..style = Styles.ts_8E9AB0_12sp
..maxLines = 1
..overflow = TextOverflow.ellipsis,
),
2.verticalSpace,
Expanded(
child: data.url.isEmpty
? ColoredBox(
color: Styles.c_F0F2F6,
child: Center(
child: StrRes.location.toText..style = Styles.ts_8E9AB0_12sp,
),
)
: ImageUtil.networkImage(
url: data.url,
width: locationWidth,
fit: BoxFit.cover,
),
),
],
),
);
}
}
@@ -10,7 +10,6 @@ class ChatToolBox extends StatelessWidget {
this.onTapCamera,
this.onTapCard,
this.onTapFile,
this.onTapLocation,
this.onTapDirectionalMessage,
});
final Function()? onTapAlbum;
@@ -18,7 +17,6 @@ class ChatToolBox extends StatelessWidget {
final Function()? onTapCall;
final Function()? onTapFile;
final Function()? onTapCard;
final Function()? onTapLocation;
final VoidCallback? onTapDirectionalMessage;
@override
@@ -50,11 +48,6 @@ class ChatToolBox extends StatelessWidget {
icon: ImageRes.toolboxCard,
onTap: onTapCard,
),
ToolboxItemInfo(
text: StrRes.toolboxLocation,
icon: ImageRes.toolboxLocation,
onTap: () => Permissions.location(onTapLocation),
),
if (onTapDirectionalMessage != null)
ToolboxItemInfo(
text: StrRes.toolboxDirectionalMessage,
@@ -1,269 +0,0 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'package:openim_common/openim_common.dart';
import 'package:geolocator/geolocator.dart';
import 'package:webview_flutter/webview_flutter.dart';
import 'package:webview_flutter_android/webview_flutter_android.dart';
import 'package:webview_flutter_wkwebview/webview_flutter_wkwebview.dart';
class ChatWebViewMap extends StatefulWidget {
const ChatWebViewMap({
super.key,
required this.host,
required this.webKey,
required this.webServerKey,
this.mapThumbnailSize = "1200*600",
this.mapBackUrl = "http://callback",
this.latitude,
this.longitude,
});
final String host;
final String webKey;
final String webServerKey;
final String mapThumbnailSize;
final String mapBackUrl;
final double? latitude;
final double? longitude;
@override
State<ChatWebViewMap> createState() => _ChatWebViewMapState();
}
class _ChatWebViewMapState extends State<ChatWebViewMap> {
WebViewController? _controller;
String url = "";
double progress = 0;
double? latitude;
double? longitude;
String? description;
late String locationUrl;
late String thumbnailUrl;
late String previewLocationUrl;
late String webKey;
late String webServerKey;
late String host;
void _configUrl() {
locationUrl = "$host?key=$webKey&serverKey=$webServerKey#/";
previewLocationUrl = "$host?key=$webKey&serverKey=$webServerKey&location=$longitude,$latitude#/";
}
String getStaticMapURL(double longitude, double latitude) {
final url =
'https://restapi.amap.com/v3/staticmap?location=$longitude,$latitude&zoom=13&size=200*200&markers=mid,,A:$longitude,$latitude&key=$webServerKey';
return url;
}
bool get isPreview => widget.longitude != null && widget.latitude != null;
@override
void initState() {
super.initState();
host = widget.host;
webKey = widget.webKey;
webServerKey = widget.webServerKey;
longitude = widget.longitude;
latitude = widget.latitude;
_determinePosition().then((position) {
longitude = position.longitude;
latitude = position.latitude;
_configUrl();
late final PlatformWebViewControllerCreationParams params;
if (WebViewPlatform.instance is WebKitWebViewPlatform) {
params = WebKitWebViewControllerCreationParams(
allowsInlineMediaPlayback: true,
mediaTypesRequiringUserAction: const <PlaybackMediaTypes>{},
);
} else {
params = const PlatformWebViewControllerCreationParams();
}
final WebViewController controller = WebViewController.fromPlatformCreationParams(params);
controller
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setNavigationDelegate(
NavigationDelegate(
onProgress: (int progress) {
debugPrint('WebView is loading (progress : $progress%)');
setState(() {
this.progress = progress / 100;
});
},
onPageStarted: (String url) {
debugPrint('Page started loading: $url');
},
onPageFinished: (String url) {
debugPrint('Page finished loading: $url');
},
onWebResourceError: (WebResourceError error) {
debugPrint(
'Page resource error: code: ${error.errorCode} description: ${error.description} errorType: ${error.errorType} isForMainFrame: ${error.isForMainFrame}');
},
onNavigationRequest: (NavigationRequest request) {
if (request.url.startsWith('https://www.youtube.com/')) {
debugPrint('blocking navigation to ${request.url}');
return NavigationDecision.prevent;
}
debugPrint('allowing navigation to ${request.url}');
return NavigationDecision.navigate;
},
onHttpError: (HttpResponseError error) {
debugPrint('Error occurred on page: ${error.response?.statusCode}');
},
onUrlChange: (UrlChange change) {
debugPrint('url change to ${change.url}');
},
onHttpAuthRequest: (HttpAuthRequest request) {},
),
)
..addJavaScriptChannel(
'getLocaltion',
onMessageReceived: (JavaScriptMessage message) {
final value = message.message;
final params = jsonDecode(value);
final locationStr = params['location'] as String;
final locations = locationStr.split(',');
longitude = double.parse(locations[0]);
latitude = double.parse(locations[1]);
final address = params['address'];
final url = getStaticMapURL(longitude!, latitude!);
final result = {
'longitude': longitude,
'latitude': latitude,
'url': url,
'addr': address,
'name': '',
};
description = jsonEncode(result);
Logger.print('$result');
_confirm();
},
)
..loadRequest(Uri.parse(previewLocationUrl));
print('previewLocationUrl: $previewLocationUrl');
if (!Platform.isMacOS) {
controller.setBackgroundColor(const Color(0x80000000));
}
if (controller.platform is AndroidWebViewController) {
AndroidWebViewController.enableDebugging(true);
(controller.platform as AndroidWebViewController).setMediaPlaybackRequiresUserGesture(false);
}
setState(() {
_controller = controller;
});
});
}
Future<Position> _determinePosition() async {
bool serviceEnabled;
LocationPermission permission;
serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) {
return Future.error('Location services are disabled.');
}
permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
if (permission == LocationPermission.denied) {
return Future.error('Location permissions are denied');
}
}
if (permission == LocationPermission.deniedForever) {
return Future.error('Location permissions are permanently denied, we cannot request permissions.');
}
return await Geolocator.getCurrentPosition();
}
@override
void dispose() {
super.dispose();
}
void _confirm() async {
if (null == latitude || null == longitude) {
await showDialog(
context: context,
builder: (_) => AlertDialog(
title: StrRes.plsSelectLocation.toText..style = Styles.ts_0C1C33_17sp_semibold,
actions: [
GestureDetector(
onTap: () => Navigator.pop(context),
behavior: HitTestBehavior.translucent,
child: Container(
padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 10.h),
child: StrRes.determine.toText..style = Styles.ts_0089FF_17sp_semibold,
),
),
],
),
);
return;
}
Navigator.pop(context, {
'latitude': latitude,
'longitude': longitude,
'description': description,
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
appBar: TitleBar.back(
onTap: () async {
Get.back();
},
title: StrRes.location,
),
body: SafeArea(
child: Stack(
children: [
_controller == null
? const Align(
child: CupertinoActivityIndicator(),
)
: WebViewWidget(controller: _controller!),
progress < 1.0
? LinearProgressIndicator(
value: progress,
color: Colors.blue,
)
: const SizedBox(),
],
),
),
);
}
}
@@ -14,11 +14,9 @@ class RichTextInputBox extends StatefulWidget {
this.showCameraIcon = true,
this.showCardIcon = true,
this.showFileIcon = true,
this.showLocationIcon = true,
this.onTapAlbum,
this.onTapCard,
this.onTapFile,
this.onTapLocation,
this.onSend,
}) : super(key: key);
final TextEditingController? controller;
@@ -29,12 +27,10 @@ class RichTextInputBox extends StatefulWidget {
final bool showCameraIcon;
final bool showFileIcon;
final bool showCardIcon;
final bool showLocationIcon;
final Function()? onTapAlbum;
final Function()? onTapCamera;
final Function()? onTapFile;
final Function()? onTapCard;
final Function()? onTapLocation;
final Function()? onSend;
@override
@@ -113,19 +109,12 @@ class _RichTextInputBoxState extends State<RichTextInputBox> {
..height = 22.h
..opacity = _opacity
..onTap = widget.onTapCard,
if (widget.showLocationIcon)
ImageRes.toolboxLocation1.toImage
..width = 16.w
..height = 22.h
..opacity = _opacity
..onTap = widget.onTapLocation,
],
),
if (widget.showAlbumIcon ||
widget.showCameraIcon ||
widget.showCardIcon ||
widget.showFileIcon ||
widget.showLocationIcon)
widget.showFileIcon)
15.verticalSpace,
Row(
children: [
@@ -72,12 +72,6 @@ class IMViews {
alignment: MainAxisAlignment.start,
onTap: () => onTapSheetItem.call(0),
),
SheetItem(
label: StrRes.callVideo,
icon: ImageRes.callVideo,
alignment: MainAxisAlignment.start,
onTap: () => onTapSheetItem.call(1),
),
],
),
);
@@ -96,11 +90,6 @@ class IMViews {
icon: ImageRes.callVoice,
onTap: () => onTapSheetItem.call(0),
),
SheetItem(
label: StrRes.callVideo,
icon: ImageRes.callVideo,
onTap: () => onTapSheetItem.call(1),
),
],
),
);
-1
View File
@@ -79,7 +79,6 @@ dependencies:
flutter_map: ^6.0.1
ffmpeg_kit_flutter_full_gpl: 6.0.3
pull_to_refresh_new: ^2.0.5
geolocator: ^12.0.0
fixnum: ^1.1.0
protobuf: ^3.0.0
intl: ^0.19.0
@@ -1,6 +1,5 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:collection/collection.dart';
import 'package:flutter/services.dart';
@@ -100,13 +99,6 @@ mixin OpenIMLive {
final callType = mediaType == 'audio' ? CallType.audio : CallType.video;
final callObj = sessionType == ConversationType.single ? CallObj.single : CallObj.group;
if (Platform.isAndroid && _isRunningBackground) {
_beCalledEvent = event;
if (await Permissions.checkSystemAlertWindow()) {
return;
}
}
_beCalledEvent = null;
OpenIMLiveClient().start(
Get.overlayContext!,
callEventSubject: signalingSubject,
-48
View File
@@ -930,54 +930,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.0.0"
geolocator:
dependency: transitive
description:
name: geolocator
sha256: "149876cc5207a0f5daf4fdd3bfcf0a0f27258b3fe95108fa084f527ad0568f1b"
url: "https://pub.dev"
source: hosted
version: "12.0.0"
geolocator_android:
dependency: transitive
description:
name: geolocator_android
sha256: "7aefc530db47d90d0580b552df3242440a10fe60814496a979aa67aa98b1fd47"
url: "https://pub.dev"
source: hosted
version: "4.6.1"
geolocator_apple:
dependency: transitive
description:
name: geolocator_apple
sha256: c4ecead17985ede9634f21500072edfcb3dba0ef7b97f8d7bc556d2d722b3ba3
url: "https://pub.dev"
source: hosted
version: "2.3.9"
geolocator_platform_interface:
dependency: transitive
description:
name: geolocator_platform_interface
sha256: "386ce3d9cce47838355000070b1d0b13efb5bc430f8ecda7e9238c8409ace012"
url: "https://pub.dev"
source: hosted
version: "4.2.4"
geolocator_web:
dependency: transitive
description:
name: geolocator_web
sha256: "2ed69328e05cd94e7eb48bb0535f5fc0c0c44d1c4fa1e9737267484d05c29b5e"
url: "https://pub.dev"
source: hosted
version: "4.1.1"
geolocator_windows:
dependency: transitive
description:
name: geolocator_windows
sha256: "53da08937d07c24b0d9952eb57a3b474e29aae2abf9dd717f7e1230995f13f0e"
url: "https://pub.dev"
source: hosted
version: "0.2.3"
gesture_password_widget:
dependency: "direct main"
description:
@@ -0,0 +1,30 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:openim_common/src/res/lang/zh_CN.dart';
import 'package:openim_common/src/widgets/chat/chat_location_view.dart';
void main() {
test('用户可见通话文案不再出现视频通话或音视频通话', () {
for (final entry in zh_CN.entries) {
expect(entry.value.contains('视频通话'), isFalse, reason: '${entry.key}=${entry.value}');
expect(entry.value.contains('音视频通话'), isFalse, reason: '${entry.key}=${entry.value}');
}
expect(zh_CN['toolboxCall'], '语音通话');
expect(zh_CN['audioAndVideoCall'], '语音通话');
expect(zh_CN['video'], '视频');
});
test('历史位置描述损坏时仍能解析成只读占位,不抛异常', () {
final ok = LocationPreviewData.parse('{"url":"http://x","name":"公司门口","addr":"一楼大厅"}');
expect(ok.name, '公司门口');
expect(ok.addr, '一楼大厅');
expect(ok.url, 'http://x');
expect(() => LocationPreviewData.parse('{not-json'), returnsNormally);
expect(() => LocationPreviewData.parse(null), returnsNormally);
expect(() => LocationPreviewData.parse(''), returnsNormally);
final broken = LocationPreviewData.parse('{not-json');
expect(broken.url, isEmpty);
expect(broken.name, isNotEmpty);
});
}