Add mock GPS simulator and configurable location sources
This commit is contained in:
@@ -1,153 +1,227 @@
|
||||
export interface LocationUpdate {
|
||||
latitude: number
|
||||
longitude: number
|
||||
accuracy?: number
|
||||
speed?: number
|
||||
import { DEFAULT_MOCK_LOCATION_BRIDGE_URL, MockLocationBridge } from './mockLocationBridge'
|
||||
import { MockLocationSource } from './mockLocationSource'
|
||||
import { RealLocationSource } from './realLocationSource'
|
||||
import { type LocationSample, type LocationSourceCallbacks, type LocationSourceMode } from './locationSource'
|
||||
|
||||
export interface LocationUpdate extends LocationSample {}
|
||||
|
||||
export interface LocationControllerDebugState {
|
||||
sourceMode: LocationSourceMode
|
||||
sourceModeText: string
|
||||
listening: boolean
|
||||
mockBridgeConnected: boolean
|
||||
mockBridgeStatusText: string
|
||||
mockBridgeUrlText: string
|
||||
mockCoordText: string
|
||||
mockSpeedText: string
|
||||
}
|
||||
|
||||
export interface LocationControllerCallbacks {
|
||||
onLocation: (update: LocationUpdate) => void
|
||||
onStatus: (message: string) => void
|
||||
onError: (message: string) => void
|
||||
onDebugStateChange?: (state: LocationControllerDebugState) => void
|
||||
}
|
||||
|
||||
function hasLocationPermission(settings: WechatMiniprogram.AuthSetting): boolean {
|
||||
const authSettings = settings as Record<string, boolean | undefined>
|
||||
return !!authSettings['scope.userLocation']
|
||||
function formatSourceModeText(mode: LocationSourceMode): string {
|
||||
return mode === 'mock' ? '模拟定位' : '真实定位'
|
||||
}
|
||||
|
||||
function hasBackgroundLocationPermission(settings: WechatMiniprogram.AuthSetting): boolean {
|
||||
const authSettings = settings as Record<string, boolean | undefined>
|
||||
return !!authSettings['scope.userLocationBackground']
|
||||
function formatMockCoordText(sample: LocationSample | null): string {
|
||||
if (!sample) {
|
||||
return '--'
|
||||
}
|
||||
|
||||
return `${sample.latitude.toFixed(6)}, ${sample.longitude.toFixed(6)}`
|
||||
}
|
||||
|
||||
function formatMockSpeedText(sample: LocationSample | null): string {
|
||||
if (!sample || !Number.isFinite(sample.speed)) {
|
||||
return '--'
|
||||
}
|
||||
|
||||
return `${(Number(sample.speed) * 3.6).toFixed(1)} km/h`
|
||||
}
|
||||
|
||||
function normalizeMockBridgeUrl(rawUrl: string): string {
|
||||
const trimmed = rawUrl.trim()
|
||||
if (!trimmed) {
|
||||
return DEFAULT_MOCK_LOCATION_BRIDGE_URL
|
||||
}
|
||||
|
||||
let normalized = trimmed
|
||||
if (!/^wss?:\/\//i.test(normalized)) {
|
||||
normalized = `ws://${normalized.replace(/^\/+/, '')}`
|
||||
}
|
||||
|
||||
if (!/\/mock-gps(?:\?.*)?$/i.test(normalized)) {
|
||||
normalized = normalized.replace(/\/+$/, '')
|
||||
normalized = `${normalized}/mock-gps`
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
export class LocationController {
|
||||
callbacks: LocationControllerCallbacks
|
||||
listening: boolean
|
||||
boundLocationHandler: ((result: WechatMiniprogram.OnLocationChangeCallbackResult) => void) | null
|
||||
realSource: RealLocationSource
|
||||
mockSource: MockLocationSource
|
||||
mockBridge: MockLocationBridge
|
||||
sourceMode: LocationSourceMode
|
||||
mockBridgeStatusText: string
|
||||
mockBridgeUrl: string
|
||||
|
||||
constructor(callbacks: LocationControllerCallbacks) {
|
||||
this.callbacks = callbacks
|
||||
this.listening = false
|
||||
this.boundLocationHandler = null
|
||||
this.sourceMode = 'real'
|
||||
this.mockBridgeUrl = DEFAULT_MOCK_LOCATION_BRIDGE_URL
|
||||
this.mockBridgeStatusText = `未连接 (${this.mockBridgeUrl})`
|
||||
|
||||
const sourceCallbacks: LocationSourceCallbacks = {
|
||||
onLocation: (sample) => {
|
||||
this.callbacks.onLocation(sample)
|
||||
this.emitDebugState()
|
||||
},
|
||||
onStatus: (message) => {
|
||||
this.callbacks.onStatus(message)
|
||||
this.emitDebugState()
|
||||
},
|
||||
onError: (message) => {
|
||||
this.callbacks.onError(message)
|
||||
this.emitDebugState()
|
||||
},
|
||||
}
|
||||
|
||||
this.realSource = new RealLocationSource(sourceCallbacks)
|
||||
this.mockSource = new MockLocationSource(sourceCallbacks)
|
||||
this.mockBridge = new MockLocationBridge({
|
||||
onOpen: () => {
|
||||
this.mockBridgeStatusText = `已连接 (${this.mockBridge.url})`
|
||||
this.callbacks.onStatus('模拟定位源已连接')
|
||||
this.emitDebugState()
|
||||
},
|
||||
onClose: () => {
|
||||
this.mockBridgeStatusText = `未连接 (${this.mockBridge.url})`
|
||||
this.callbacks.onStatus('模拟定位源已断开')
|
||||
this.emitDebugState()
|
||||
},
|
||||
onError: (message) => {
|
||||
this.mockBridgeStatusText = `连接失败 (${this.mockBridge.url})`
|
||||
this.callbacks.onError(`模拟定位源错误: ${message}`)
|
||||
this.emitDebugState()
|
||||
},
|
||||
onSample: (sample) => {
|
||||
this.mockSource.pushSample(sample)
|
||||
this.emitDebugState()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
get listening(): boolean {
|
||||
return this.sourceMode === 'mock' ? this.mockSource.active : this.realSource.active
|
||||
}
|
||||
|
||||
getDebugState(): LocationControllerDebugState {
|
||||
return {
|
||||
sourceMode: this.sourceMode,
|
||||
sourceModeText: formatSourceModeText(this.sourceMode),
|
||||
listening: this.listening,
|
||||
mockBridgeConnected: this.mockBridge.connected,
|
||||
mockBridgeStatusText: this.mockBridgeStatusText,
|
||||
mockBridgeUrlText: this.mockBridgeUrl,
|
||||
mockCoordText: formatMockCoordText(this.mockSource.lastSample),
|
||||
mockSpeedText: formatMockSpeedText(this.mockSource.lastSample),
|
||||
}
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.listening) {
|
||||
this.callbacks.onStatus('后台持续定位进行中')
|
||||
return
|
||||
}
|
||||
|
||||
wx.getSetting({
|
||||
success: (result) => {
|
||||
const settings = result.authSetting || {}
|
||||
if (hasBackgroundLocationPermission(settings)) {
|
||||
this.startBackgroundLocation()
|
||||
return
|
||||
}
|
||||
|
||||
if (hasLocationPermission(settings)) {
|
||||
this.requestBackgroundPermissionInSettings()
|
||||
return
|
||||
}
|
||||
|
||||
wx.authorize({
|
||||
scope: 'scope.userLocation',
|
||||
success: () => {
|
||||
this.requestBackgroundPermissionInSettings()
|
||||
},
|
||||
fail: () => {
|
||||
this.requestBackgroundPermissionInSettings()
|
||||
},
|
||||
})
|
||||
},
|
||||
fail: (error) => {
|
||||
const message = error && error.errMsg ? error.errMsg : 'getSetting 失败'
|
||||
this.callbacks.onError(`GPS授权检查失败: ${message}`)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
requestBackgroundPermissionInSettings(): void {
|
||||
this.callbacks.onStatus('请在授权面板开启后台定位')
|
||||
wx.openSetting({
|
||||
success: (result) => {
|
||||
const settings = result.authSetting || {}
|
||||
if (hasBackgroundLocationPermission(settings)) {
|
||||
this.startBackgroundLocation()
|
||||
return
|
||||
}
|
||||
|
||||
this.callbacks.onError('GPS启动失败: 未授予后台定位权限')
|
||||
},
|
||||
fail: (error) => {
|
||||
const message = error && error.errMsg ? error.errMsg : 'openSetting 失败'
|
||||
this.callbacks.onError(`GPS启动失败: ${message}`)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
startBackgroundLocation(): void {
|
||||
wx.startLocationUpdateBackground({
|
||||
type: 'wgs84',
|
||||
success: () => {
|
||||
this.bindLocationListener()
|
||||
this.listening = true
|
||||
this.callbacks.onStatus('后台持续定位已启动')
|
||||
},
|
||||
fail: (error) => {
|
||||
const message = error && error.errMsg ? error.errMsg : 'startLocationUpdateBackground 失败'
|
||||
this.callbacks.onError(`GPS启动失败: ${message}`)
|
||||
},
|
||||
})
|
||||
this.getActiveSource().start()
|
||||
this.emitDebugState()
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (!this.listening) {
|
||||
this.callbacks.onStatus('后台持续定位未启动')
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof wx.offLocationChange === 'function' && this.boundLocationHandler) {
|
||||
wx.offLocationChange(this.boundLocationHandler)
|
||||
}
|
||||
this.boundLocationHandler = null
|
||||
|
||||
wx.stopLocationUpdate({
|
||||
complete: () => {
|
||||
this.listening = false
|
||||
this.callbacks.onStatus('后台持续定位已停止')
|
||||
},
|
||||
})
|
||||
this.getActiveSource().stop()
|
||||
this.emitDebugState()
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (typeof wx.offLocationChange === 'function' && this.boundLocationHandler) {
|
||||
wx.offLocationChange(this.boundLocationHandler)
|
||||
}
|
||||
this.boundLocationHandler = null
|
||||
|
||||
if (this.listening) {
|
||||
wx.stopLocationUpdate({ complete: () => {} })
|
||||
this.listening = false
|
||||
}
|
||||
this.realSource.destroy()
|
||||
this.mockSource.destroy()
|
||||
this.mockBridge.destroy()
|
||||
this.emitDebugState()
|
||||
}
|
||||
|
||||
bindLocationListener(): void {
|
||||
if (this.boundLocationHandler) {
|
||||
setSourceMode(mode: LocationSourceMode): void {
|
||||
if (this.sourceMode === mode) {
|
||||
this.callbacks.onStatus(`${formatSourceModeText(mode)}已启用`)
|
||||
this.emitDebugState()
|
||||
return
|
||||
}
|
||||
|
||||
this.boundLocationHandler = (result) => {
|
||||
this.callbacks.onLocation({
|
||||
latitude: result.latitude,
|
||||
longitude: result.longitude,
|
||||
accuracy: result.accuracy,
|
||||
speed: result.speed,
|
||||
})
|
||||
const wasListening = this.listening
|
||||
if (wasListening) {
|
||||
this.getActiveSource().stop()
|
||||
}
|
||||
this.sourceMode = mode
|
||||
|
||||
if (wasListening) {
|
||||
this.getActiveSource().start()
|
||||
} else {
|
||||
this.callbacks.onStatus(`已切换到${formatSourceModeText(mode)}`)
|
||||
}
|
||||
|
||||
wx.onLocationChange(this.boundLocationHandler)
|
||||
this.emitDebugState()
|
||||
}
|
||||
|
||||
setMockBridgeUrl(url: string): void {
|
||||
this.mockBridgeUrl = normalizeMockBridgeUrl(url)
|
||||
|
||||
if (this.mockBridge.connected || this.mockBridge.connecting) {
|
||||
this.mockBridgeStatusText = `已设置新地址,重连生效 (${this.mockBridgeUrl})`
|
||||
this.callbacks.onStatus('模拟定位源地址已更新,重连后生效')
|
||||
} else {
|
||||
this.mockBridgeStatusText = `未连接 (${this.mockBridgeUrl})`
|
||||
this.callbacks.onStatus('模拟定位源地址已更新')
|
||||
}
|
||||
|
||||
this.emitDebugState()
|
||||
}
|
||||
|
||||
connectMockBridge(url = DEFAULT_MOCK_LOCATION_BRIDGE_URL): void {
|
||||
if (this.mockBridge.connected || this.mockBridge.connecting) {
|
||||
this.callbacks.onStatus('模拟定位源已连接')
|
||||
this.emitDebugState()
|
||||
return
|
||||
}
|
||||
|
||||
const targetUrl = normalizeMockBridgeUrl(url === DEFAULT_MOCK_LOCATION_BRIDGE_URL ? this.mockBridgeUrl : url)
|
||||
this.mockBridgeUrl = targetUrl
|
||||
this.mockBridgeStatusText = `连接中 (${targetUrl})`
|
||||
this.emitDebugState()
|
||||
this.callbacks.onStatus('模拟定位源连接中')
|
||||
this.mockBridge.connect(targetUrl)
|
||||
}
|
||||
|
||||
disconnectMockBridge(): void {
|
||||
if (!this.mockBridge.connected && !this.mockBridge.connecting) {
|
||||
this.callbacks.onStatus('模拟定位源未连接')
|
||||
this.emitDebugState()
|
||||
return
|
||||
}
|
||||
|
||||
this.mockBridge.disconnect()
|
||||
this.mockBridgeStatusText = `未连接 (${this.mockBridge.url})`
|
||||
this.callbacks.onStatus('模拟定位源已断开')
|
||||
this.emitDebugState()
|
||||
}
|
||||
|
||||
getActiveSource(): RealLocationSource | MockLocationSource {
|
||||
return this.sourceMode === 'mock' ? this.mockSource : this.realSource
|
||||
}
|
||||
|
||||
emitDebugState(): void {
|
||||
if (this.callbacks.onDebugStateChange) {
|
||||
this.callbacks.onDebugStateChange(this.getDebugState())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user