Browse Source
- 定位插件新增 getCurrentPlace:安卓高德定位(华为上高德只回坐标时退回系统 Geocoder),
iOS CoreLocation + CLGeocoder;不在插件里弹权限框,失败回原因
- DeviceLocationService:启动/回前台刷新、10 分钟节流、落盘缓存;香薰首次连上(前台)申请一次
「使用期间」定位权限,拒绝后不再追问;建会话只读缓存,不拖慢唤醒
- BailianMultimodalService.start:仅对登记的应用(车载香薰)带
client_info.location{latitude,longitude,city_name} 与 biz_params.user_prompt_params{location,date},
date 精确到分;拿不到地名时用坐标描述。EMAI 耳机的 Start 报文不变
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
main
9 changed files with 511 additions and 26 deletions
@ -0,0 +1,189 @@ |
|||
import 'dart:async'; |
|||
|
|||
import 'package:flutter/widgets.dart'; |
|||
import 'package:get/get.dart'; |
|||
import 'package:get_storage/get_storage.dart'; |
|||
import 'package:location_service/location_service.dart'; |
|||
import 'package:permission_handler/permission_handler.dart'; |
|||
|
|||
import '../../core/utils/logger.dart'; |
|||
import '../../devices/device_hub.dart'; |
|||
import '../../devices/device_vendors.dart'; |
|||
|
|||
/// 给百炼会话提供「用户在哪、现在几点」(2026-09-25,车载香薰)。 |
|||
/// |
|||
/// 车载助手的提示词里有「系统条件」:`${location}`(用户当前位置)、`${date}`(今天的日期)。 |
|||
/// 以前一个都没传,AI 问天气只能反问「你要查哪里」。现在两条通道一起给 |
|||
/// (见 [BailianMultimodalService.start]): |
|||
/// - `client_info.location` = {latitude, longitude, city_name}(lumi 也是这么传经纬度的); |
|||
/// - `biz_params.user_prompt_params` = {location: 省市区, date: 年月日 星期 时:分}。 |
|||
/// |
|||
/// 节奏照 lumi 的 LocationService:启动 / 回到前台时刷新,10 分钟内不重复,结果落盘; |
|||
/// **建会话时只读缓存,从不等定位**,所以不拖慢唤醒。 |
|||
/// |
|||
/// 权限:车载香薰第一次连上(且 App 在前台)时申请一次「使用期间」定位权限, |
|||
/// 用户拒绝后不再追问——没有权限就只是不带位置。 |
|||
class DeviceLocationService extends GetxService with WidgetsBindingObserver { |
|||
static const String _tag = 'DeviceLocation'; |
|||
static DeviceLocationService? get maybe => |
|||
Get.isRegistered<DeviceLocationService>() |
|||
? Get.find<DeviceLocationService>() |
|||
: null; |
|||
|
|||
static const String _cacheKey = 'device_location_cache'; |
|||
static const String _askedKey = 'device_location_perm_asked'; |
|||
static const Duration _minRefreshInterval = Duration(minutes: 10); |
|||
|
|||
final GetStorage _storage = GetStorage(); |
|||
Map<String, dynamic>? _place; |
|||
DateTime? _lastRefreshAt; |
|||
bool _refreshing = false; |
|||
Worker? _hubWorker; |
|||
AppLifecycleState? _lifecycle = AppLifecycleState.resumed; |
|||
|
|||
@override |
|||
void onInit() { |
|||
super.onInit(); |
|||
WidgetsBinding.instance.addObserver(this); |
|||
try { |
|||
final c = _storage.read<Map>(_cacheKey); |
|||
if (c != null) _place = c.map((k, v) => MapEntry('$k', v)); |
|||
} catch (_) {} |
|||
} |
|||
|
|||
@override |
|||
void onReady() { |
|||
super.onReady(); |
|||
unawaited(_logReady()); |
|||
refresh(); |
|||
// DeviceHub 在 InitialBinding 里晚于本服务注册,所以到 onReady 再盯; |
|||
// 香薰可能在这之前就已经回连上了,所以先按当前列表判一次 |
|||
if (Get.isRegistered<DeviceHub>()) { |
|||
_checkSessions(DeviceHub.to.sessions); |
|||
_hubWorker = ever<List>(DeviceHub.to.sessions, _checkSessions); |
|||
} else { |
|||
Logger.w(_tag, 'DeviceHub 未注册,连上香薰时不会申请定位权限'); |
|||
} |
|||
} |
|||
|
|||
void _checkSessions(List list) { |
|||
if (list.any((s) => s.vendor == DeviceVendors.eaicar)) { |
|||
unawaited(askPermissionOnce()); |
|||
} |
|||
} |
|||
|
|||
Future<void> _logReady() async { |
|||
try { |
|||
final st = await Permission.locationWhenInUse.status; |
|||
Logger.i(_tag, '位置服务就绪(定位权限=$st,缓存位置=${placeText()})'); |
|||
} catch (_) {} |
|||
} |
|||
|
|||
@override |
|||
void onClose() { |
|||
WidgetsBinding.instance.removeObserver(this); |
|||
_hubWorker?.dispose(); |
|||
super.onClose(); |
|||
} |
|||
|
|||
@override |
|||
void didChangeAppLifecycleState(AppLifecycleState state) { |
|||
_lifecycle = state; |
|||
if (state == AppLifecycleState.resumed) refresh(); |
|||
} |
|||
|
|||
/// 车载香薰第一次连上时申请一次定位权限。只问一次,拒绝后不再打扰。 |
|||
Future<void> askPermissionOnce() async { |
|||
if (_storage.read<bool>(_askedKey) == true) return; |
|||
// 权限框要在前台弹;在后台(被动回连)时先不问,下次前台连上再说 |
|||
if (_lifecycle != AppLifecycleState.resumed) { |
|||
Logger.i(_tag, 'App 不在前台($_lifecycle),暂不申请定位权限'); |
|||
return; |
|||
} |
|||
await _storage.write(_askedKey, true); |
|||
try { |
|||
final st = await Permission.locationWhenInUse.request(); |
|||
Logger.i(_tag, '车载香薰首次连接,申请定位权限 → $st'); |
|||
if (st.isGranted || st.isLimited) await refresh(force: true); |
|||
} catch (e) { |
|||
Logger.w(_tag, '申请定位权限失败: $e'); |
|||
} |
|||
} |
|||
|
|||
/// 后台刷新一次位置(不弹权限框、不抛异常)。 |
|||
Future<void> refresh({bool force = false}) async { |
|||
if (_refreshing) return; |
|||
final last = _lastRefreshAt; |
|||
if (!force && |
|||
last != null && |
|||
DateTime.now().difference(last) < _minRefreshInterval) { |
|||
return; |
|||
} |
|||
_refreshing = true; |
|||
try { |
|||
final st = await Permission.locationWhenInUse.status; |
|||
if (!(st.isGranted || st.isLimited)) { |
|||
Logger.i(_tag, '没有定位权限($st),不取位置'); |
|||
return; |
|||
} |
|||
final r = await LocationService().currentPlace(); |
|||
if (r['error'] != null) { |
|||
Logger.w(_tag, '定位未成功: ${r['error']}(沿用缓存: ${placeText()})'); |
|||
return; |
|||
} |
|||
_place = r; |
|||
_lastRefreshAt = DateTime.now(); |
|||
await _storage.write(_cacheKey, r); |
|||
Logger.i(_tag, '位置已更新: ${placeText()}(原始: $r)'); |
|||
} catch (e) { |
|||
Logger.w(_tag, '刷新位置失败: $e'); |
|||
} finally { |
|||
_refreshing = false; |
|||
} |
|||
} |
|||
|
|||
String _s(String k) => (_place?[k] ?? '').toString().trim(); |
|||
|
|||
/// `${location}` 的取值:省市区(去掉重复的直辖市名),拿不到就是「未知」。 |
|||
String placeText() { |
|||
final parts = <String>[]; |
|||
for (final k in ['province', 'city', 'district']) { |
|||
final v = _s(k); |
|||
if (v.isNotEmpty && !parts.contains(v)) parts.add(v); |
|||
} |
|||
if (parts.isEmpty) { |
|||
final addr = _s('address'); |
|||
if (addr.isNotEmpty) return addr; |
|||
// 反查不到地名时用坐标描述,模型/插件还能据此判断城市,比「未知」强 |
|||
final lat = _place?['latitude']; |
|||
final lng = _place?['longitude']; |
|||
if (lat is num && lng is num) { |
|||
return '经纬度 ${lat.toStringAsFixed(4)},${lng.toStringAsFixed(4)}'; |
|||
} |
|||
return '未知'; |
|||
} |
|||
return parts.join(); |
|||
} |
|||
|
|||
/// `client_info.location`:没有经纬度时返回 null(整块不发)。 |
|||
Map<String, String>? clientLocation() { |
|||
final lat = _place?['latitude']; |
|||
final lng = _place?['longitude']; |
|||
if (lat is! num || lng is! num) return null; |
|||
final city = _s('city'); |
|||
return { |
|||
'latitude': lat.toStringAsFixed(6), |
|||
'longitude': lng.toStringAsFixed(6), |
|||
if (city.isNotEmpty) 'city_name': city, |
|||
}; |
|||
} |
|||
|
|||
/// `${date}` 的取值:手机本地时间,形如「2026年9月25日 星期四 21:30」。 |
|||
static String dateText([DateTime? now]) { |
|||
final t = now ?? DateTime.now(); |
|||
const week = ['一', '二', '三', '四', '五', '六', '日']; |
|||
String two(int v) => v.toString().padLeft(2, '0'); |
|||
return '${t.year}年${t.month}月${t.day}日 星期${week[t.weekday - 1]} ' |
|||
'${two(t.hour)}:${two(t.minute)}'; |
|||
} |
|||
} |
|||
@ -0,0 +1,125 @@ |
|||
package com.yunqiinnovation.location_service |
|||
|
|||
import android.Manifest |
|||
import android.content.Context |
|||
import android.content.pm.PackageManager |
|||
import android.os.Handler |
|||
import android.os.Looper |
|||
import android.util.Log |
|||
import androidx.core.content.ContextCompat |
|||
import com.amap.api.location.AMapLocationClient |
|||
import com.amap.api.location.AMapLocationClientOption |
|||
|
|||
/** |
|||
* 取一次「当前位置 + 省市区」,给百炼会话的 `${location}` 用(2026-09-25,车载香薰)。 |
|||
* |
|||
* 刻意不复用 [LocationService]:那套只收 50 米内的高精度结果、超时 60 秒,是给导航/签到用的; |
|||
* 这里只要知道「在哪个城市哪个区」,网络定位就够,要的是快。 |
|||
* |
|||
* - 没有定位权限直接回 `no_permission`,**不在这里弹权限框**(由 Dart 侧决定何时申请); |
|||
* - 高德定位自带逆地理(setNeedAddress),省/市/区一次拿全,华为无 GMS 的机器上也能用; |
|||
* - 坐标是高德的 GCJ-02,只用于城市级描述,偏移无关紧要。 |
|||
*/ |
|||
object PlaceLocator { |
|||
/** 系统 Geocoder 反查省市区;机器不支持 / 没网 / 出错都返回 null。在后台线程调用。 */ |
|||
private fun systemGeocode(context: Context, lat: Double, lng: Double): Map<String, Any?>? { |
|||
return try { |
|||
if (!android.location.Geocoder.isPresent()) { |
|||
Log.w(TAG, "系统 Geocoder 不可用") |
|||
return null |
|||
} |
|||
@Suppress("DEPRECATION") |
|||
val list = android.location.Geocoder(context, java.util.Locale.CHINA).getFromLocation(lat, lng, 1) |
|||
val a = list?.firstOrNull() ?: return null |
|||
Log.i(TAG, "系统 Geocoder: 省=${a.adminArea} 市=${a.locality} 区=${a.subLocality} 地址=${a.getAddressLine(0)}") |
|||
mapOf( |
|||
"province" to (a.adminArea ?: ""), |
|||
"city" to (a.locality ?: a.adminArea ?: ""), |
|||
"district" to (a.subLocality ?: ""), |
|||
"street" to (a.thoroughfare ?: ""), |
|||
"address" to (a.getAddressLine(0) ?: ""), |
|||
) |
|||
} catch (e: Exception) { |
|||
Log.w(TAG, "系统 Geocoder 失败: ${e.message}") |
|||
null |
|||
} |
|||
} |
|||
|
|||
private const val TAG = "PlaceLocator" |
|||
private val main = Handler(Looper.getMainLooper()) |
|||
|
|||
fun locate(context: Context, timeoutMs: Long, callback: (Map<String, Any?>?, String?) -> Unit) { |
|||
val app = context.applicationContext |
|||
val fine = ContextCompat.checkSelfPermission(app, Manifest.permission.ACCESS_FINE_LOCATION) |
|||
val coarse = ContextCompat.checkSelfPermission(app, Manifest.permission.ACCESS_COARSE_LOCATION) |
|||
if (fine != PackageManager.PERMISSION_GRANTED && coarse != PackageManager.PERMISSION_GRANTED) { |
|||
callback(null, "no_permission") |
|||
return |
|||
} |
|||
main.post { |
|||
var done = false |
|||
var client: AMapLocationClient? = null |
|||
fun finish(result: Map<String, Any?>?, error: String?) { |
|||
if (done) return |
|||
done = true |
|||
try { |
|||
client?.stopLocation() |
|||
client?.onDestroy() |
|||
} catch (_: Exception) { |
|||
} |
|||
callback(result, error) |
|||
} |
|||
try { |
|||
AMapLocationClient.updatePrivacyShow(app, true, true) |
|||
AMapLocationClient.updatePrivacyAgree(app, true) |
|||
val c = AMapLocationClient(app) |
|||
client = c |
|||
// 高德的 setter 返回 option 本身(链式),Kotlin 属性赋值写法编不过,只能直接调 |
|||
val option = AMapLocationClientOption() |
|||
option.setLocationMode(AMapLocationClientOption.AMapLocationMode.Hight_Accuracy) |
|||
option.setOnceLocation(true) |
|||
option.setOnceLocationLatest(false) // 要快,不等更精的那一次 |
|||
option.setNeedAddress(true) |
|||
option.setLocationCacheEnable(true) |
|||
option.setHttpTimeOut(timeoutMs) |
|||
option.setMockEnable(false) |
|||
c.setLocationOption(option) |
|||
c.setLocationListener { loc -> |
|||
if (loc == null) { |
|||
finish(null, "null_location") |
|||
} else if (loc.errorCode != 0) { |
|||
Log.w(TAG, "定位失败 code=${loc.errorCode} ${loc.errorInfo}") |
|||
finish(null, "amap_${loc.errorCode}") |
|||
} else { |
|||
Log.i(TAG, "定位成功 type=${loc.locationType} lat=${loc.latitude} lng=${loc.longitude} " + |
|||
"省=${loc.province} 市=${loc.city} 区=${loc.district} 地址=${loc.address}") |
|||
val base = mapOf<String, Any?>( |
|||
"latitude" to loc.latitude, |
|||
"longitude" to loc.longitude, |
|||
"province" to loc.province, |
|||
"city" to loc.city, |
|||
"district" to loc.district, |
|||
"street" to loc.street, |
|||
"address" to loc.address, |
|||
) |
|||
if (!loc.city.isNullOrBlank() || !loc.address.isNullOrBlank()) { |
|||
finish(base, null) |
|||
} else { |
|||
// 2026-09-25 华为 ALN-AL80 实测:高德只回了坐标(type=12)、省市区全空。 |
|||
// 退一步用系统 Geocoder 反查;再不行就只回坐标,由上层用坐标描述位置。 |
|||
Thread { |
|||
val geo = systemGeocode(app, loc.latitude, loc.longitude) |
|||
main.post { finish(if (geo != null) base + geo else base, null) } |
|||
}.start() |
|||
} |
|||
} |
|||
} |
|||
main.postDelayed({ finish(null, "timeout") }, timeoutMs) |
|||
c.startLocation() |
|||
} catch (e: Exception) { |
|||
Log.w(TAG, "定位异常: ${e.message}") |
|||
finish(null, "exception: ${e.message}") |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,79 @@ |
|||
import Foundation |
|||
import CoreLocation |
|||
|
|||
/// 取一次「当前位置 + 省市区」,给百炼会话的 `${location}` 用(2026-09-25,车载香薰)。 |
|||
/// |
|||
/// 刻意不复用 `LocationService`:那套带缓存/预热/权限请求,是给别的业务用的; |
|||
/// 这里只要城市级描述,要快、要可控。 |
|||
/// |
|||
/// - 没有定位权限直接回 `no_permission`,**不在这里弹权限框**(由 Dart 侧决定何时申请); |
|||
/// - 逆地理用系统 `CLGeocoder`(国内是高德数据),省/市/区取 administrativeArea/locality/subLocality; |
|||
/// 直辖市 locality 可能为空,退回 administrativeArea; |
|||
/// - 逆地理失败时仍回经纬度(地名留空),经纬度本身对 client_info 也有用。 |
|||
final class PlaceLocator: NSObject, CLLocationManagerDelegate { |
|||
static let shared = PlaceLocator() |
|||
|
|||
private var manager: CLLocationManager? |
|||
private var callbacks: [([String: Any]?, String?) -> Void] = [] |
|||
private var timer: Timer? |
|||
private let geocoder = CLGeocoder() |
|||
|
|||
func locate(timeout: TimeInterval, completion: @escaping ([String: Any]?, String?) -> Void) { |
|||
DispatchQueue.main.async { |
|||
let status = CLLocationManager().authorizationStatus |
|||
guard status == .authorizedWhenInUse || status == .authorizedAlways else { |
|||
completion(nil, "no_permission") |
|||
return |
|||
} |
|||
self.callbacks.append(completion) |
|||
if self.callbacks.count > 1 { return } // 已有一次在途,结果共享 |
|||
let m = CLLocationManager() |
|||
m.delegate = self |
|||
m.desiredAccuracy = kCLLocationAccuracyHundredMeters |
|||
self.manager = m |
|||
self.timer = Timer.scheduledTimer(withTimeInterval: timeout, repeats: false) { [weak self] _ in |
|||
self?.finish(nil, "timeout") |
|||
} |
|||
m.requestLocation() |
|||
} |
|||
} |
|||
|
|||
private func finish(_ result: [String: Any]?, _ error: String?) { |
|||
timer?.invalidate() |
|||
timer = nil |
|||
manager?.delegate = nil |
|||
manager = nil |
|||
let cbs = callbacks |
|||
callbacks.removeAll() |
|||
cbs.forEach { $0(result, error) } |
|||
} |
|||
|
|||
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { |
|||
guard let loc = locations.last else { return } |
|||
guard !callbacks.isEmpty else { return } |
|||
timer?.invalidate() |
|||
var base: [String: Any] = [ |
|||
"latitude": loc.coordinate.latitude, |
|||
"longitude": loc.coordinate.longitude, |
|||
] |
|||
geocoder.reverseGeocodeLocation(loc, preferredLocale: Locale(identifier: "zh_CN")) { [weak self] marks, err in |
|||
DispatchQueue.main.async { |
|||
if let p = marks?.first { |
|||
let province = p.administrativeArea ?? "" |
|||
base["province"] = province |
|||
base["city"] = (p.locality?.isEmpty == false) ? p.locality! : province |
|||
base["district"] = p.subLocality ?? "" |
|||
base["street"] = p.thoroughfare ?? "" |
|||
base["address"] = p.name ?? "" |
|||
} else if let err = err { |
|||
NSLog("[PlaceLocator] 逆地理失败: \(err.localizedDescription)") |
|||
} |
|||
self?.finish(base, nil) |
|||
} |
|||
} |
|||
} |
|||
|
|||
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { |
|||
finish(nil, "location_error: \(error.localizedDescription)") |
|||
} |
|||
} |
|||
@ -0,0 +1,11 @@ |
|||
import 'package:eaimar/data/services/device_location_service.dart'; |
|||
import 'package:flutter_test/flutter_test.dart'; |
|||
|
|||
void main() { |
|||
test('百炼 \${date}:年月日 星期 时:分', () { |
|||
expect(DeviceLocationService.dateText(DateTime(2026, 9, 25, 21, 5)), |
|||
'2026年9月25日 星期五 21:05'); |
|||
expect(DeviceLocationService.dateText(DateTime(2026, 9, 27, 9, 0)), |
|||
'2026年9月27日 星期日 09:00'); |
|||
}); |
|||
} |
|||
Loading…
Reference in new issue