also looking at this
fix(location): unify watch handles across mobile surfaces
#268
4 files
+168
-13
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -85,6 +85,7 @@ function loadPage(beforeInject?: (page: Record<string, any>) => void, seed = SEE | ||
| 85 | 85 | return { |
| 86 | 86 | craft, |
| 87 | 87 | last, |
| 88 | count: (action: string) => posts.filter(message => message.action === action).length, | |
| 88 | 89 | // What Swift's resolveCallback and rejectCallback evaluate. |
| 89 | 90 | answer: (action: string, value: unknown) => craft._resolveCallback(last(action).callbackId, value), |
| 90 | 91 | refuse: (action: string, message: string, code: string) => craft._rejectCallback(last(action).callbackId, message, code), |
| @@ -96,6 +97,8 @@ function loadPage(beforeInject?: (page: Record<string, any>) => void, seed = SEE | ||
| 96 | 97 | page.dispatchEvent(new CustomEvent('craftNotificationResponse', { detail })), |
| 97 | 98 | receive: (detail: unknown) => |
| 98 | 99 | page.dispatchEvent(new CustomEvent('craftNotificationReceived', { detail })), |
| 100 | position: (detail: unknown) => | |
| 101 | page.dispatchEvent(new CustomEvent('craftLocationUpdate', { detail })), | |
| 99 | 102 | } |
| 100 | 103 | } |
| 101 | 104 | |
| @@ -108,7 +111,6 @@ describe('the injected iOS page script', () => { | ||
| 108 | 111 | ['startListening', craft => craft.startListening()], |
| 109 | 112 | ['stopListening', craft => craft.stopListening()], |
| 110 | 113 | ['watchPosition', craft => craft.geolocation.watchPosition(() => {})], |
| 111 | ['clearWatch', craft => craft.geolocation.clearWatch()], | |
| 112 | 114 | ] |
| 113 | 115 | |
| 114 | 116 | for (const [action, call] of calls) { |
| @@ -123,6 +125,12 @@ describe('the injected iOS page script', () => { | ||
| 123 | 125 | }) |
| 124 | 126 | } |
| 125 | 127 | |
| 128 | it('settles a legacy clear with no active watch without posting a redundant stop', async () => { | |
| 129 | const page = loadPage() | |
| 130 | expect(await page.craft.geolocation.clearWatch()).toBe(true) | |
| 131 | expect(page.count('clearWatch')).toBe(0) | |
| 132 | }) | |
| 133 | ||
| 126 | 134 | // #226: the counter restarted at 0 on every injection, so the seventh call |
| 127 | 135 | // of a reloaded page drew `cb_7` again — and an answer still owed to the |
| 128 | 136 | // previous page's `cb_7` settled it, with that call's result or its TIMEOUT. |
| @@ -165,19 +173,86 @@ describe('the injected iOS page script', () => { | ||
| 165 | 173 | await expect(haptic).rejects.toMatchObject({ code: 'CAPABILITY_DISABLED' }) |
| 166 | 174 | }) |
| 167 | 175 | |
| 168 | it('sends the v1 location clearWatch with an id once the last watch leaves', async () => { | |
| 176 | it('makes the flat location pair use numeric v1 handles and one native stream', async () => { | |
| 169 | 177 | const page = loadPage() |
| 170 | const first = page.craft.location.watchPosition(() => {}) | |
| 178 | const first = page.craft.watchPosition(() => {}) | |
| 171 | 179 | const second = page.craft.location.watchPosition(() => {}) |
| 180 | expect(typeof first).toBe('number') | |
| 181 | expect(typeof second).toBe('number') | |
| 182 | expect(second).not.toBe(first) | |
| 183 | expect(page.count('watchPosition')).toBe(1) | |
| 172 | 184 | page.answer('watchPosition', true) |
| 173 | 185 | |
| 174 | page.craft.location.clearWatch(first) | |
| 186 | page.craft.clearWatch(first) | |
| 175 | 187 | expect(() => page.last('clearWatch')).toThrow() |
| 176 | 188 | page.craft.location.clearWatch(second) |
| 177 | expect(page.last('clearWatch').callbackId).toMatch(/^cb_\d+$/) | |
| 189 | expect(page.count('clearWatch')).toBe(1) | |
| 178 | 190 | page.answer('clearWatch', true) |
| 179 | 191 | }) |
| 180 | 192 | |
| 193 | it('keeps no-argument clearWatch compatible by clearing every active handle once', () => { | |
| 194 | const page = loadPage() | |
| 195 | let calls = 0 | |
| 196 | page.craft.watchPosition(() => calls++) | |
| 197 | page.craft.location.watchPosition(() => calls++) | |
| 198 | page.answer('watchPosition', true) | |
| 199 | ||
| 200 | page.craft.clearWatch() | |
| 201 | expect(page.count('clearWatch')).toBe(1) | |
| 202 | page.position({ latitude: 1 }) | |
| 203 | expect(calls).toBe(0) | |
| 204 | ||
| 205 | page.craft.clearWatch() | |
| 206 | expect(page.count('clearWatch')).toBe(1) | |
| 207 | }) | |
| 208 | ||
| 209 | it('drops a refused v1 start so the next subscriber asks native again', async () => { | |
| 210 | const page = loadPage() | |
| 211 | let calls = 0 | |
| 212 | page.craft.watchPosition(() => calls++) | |
| 213 | page.refuse('watchPosition', 'Location is disabled', 'CAPABILITY_DISABLED') | |
| 214 | await Promise.resolve() | |
| 215 | ||
| 216 | page.position({ latitude: 1 }) | |
| 217 | expect(calls).toBe(0) | |
| 218 | page.craft.watchPosition(() => calls++) | |
| 219 | expect(page.count('watchPosition')).toBe(2) | |
| 220 | page.refuse('watchPosition', 'Location is disabled', 'CAPABILITY_DISABLED') | |
| 221 | await Promise.resolve() | |
| 222 | }) | |
| 223 | ||
| 224 | it('removes a refused legacy listener instead of leaving a dead watch behind', async () => { | |
| 225 | const page = loadPage() | |
| 226 | let calls = 0 | |
| 227 | const started = page.craft.geolocation.watchPosition(() => calls++) | |
| 228 | page.refuse('watchPosition', 'Location is disabled', 'CAPABILITY_DISABLED') | |
| 229 | ||
| 230 | await expect(started).rejects.toMatchObject({ code: 'CAPABILITY_DISABLED' }) | |
| 231 | page.position({ latitude: 1 }) | |
| 232 | expect(calls).toBe(0) | |
| 233 | }) | |
| 234 | ||
| 235 | it('does not let a legacy clear stop v1 subscribers sharing the native stream', async () => { | |
| 236 | const page = loadPage() | |
| 237 | let v1Calls = 0 | |
| 238 | let legacyCalls = 0 | |
| 239 | const id = page.craft.location.watchPosition(() => v1Calls++) | |
| 240 | page.answer('watchPosition', true) | |
| 241 | await Promise.resolve() | |
| 242 | ||
| 243 | const legacyStarted = page.craft.geolocation.watchPosition(() => legacyCalls++) | |
| 244 | expect(page.count('watchPosition')).toBe(1) | |
| 245 | expect(await legacyStarted).toBe(true) | |
| 246 | expect(await page.craft.geolocation.clearWatch()).toBe(true) | |
| 247 | expect(page.count('clearWatch')).toBe(0) | |
| 248 | ||
| 249 | page.position({ latitude: 1 }) | |
| 250 | expect(v1Calls).toBe(1) | |
| 251 | expect(legacyCalls).toBe(0) | |
| 252 | page.craft.location.clearWatch(id) | |
| 253 | expect(page.count('clearWatch')).toBe(1) | |
| 254 | }) | |
| 255 | ||
| 181 | 256 | // Haptics are feedback. An app that left enableHaptics off must not have |
| 182 | 257 | // `await haptics.selection()` stop the flow it sits in, which is how the |
| 183 | 258 | // SDK documents it and how Android and the web fallback behave. |
| @@ -2125,10 +2125,10 @@ struct CraftWebView: UIViewRepresentable { | ||
| 2125 | 2125 | return this.geolocation.getCurrentPosition({}); |
| 2126 | 2126 | }, |
| 2127 | 2127 | watchPosition: function(callback) { |
| 2128 | return this.geolocation.watchPosition(callback); | |
| 2128 | return this.location.watchPosition(callback); | |
| 2129 | 2129 | }, |
| 2130 | 2130 | clearWatch: function(watchId) { |
| 2131 | return this.geolocation.clearWatch(watchId); | |
| 2131 | return this.location.clearWatch(watchId); | |
| 2132 | 2132 | }, |
| 2133 | 2133 | getContacts: function() { |
| 2134 | 2134 | return this._invoke('getContacts'); |
| @@ -3253,21 +3253,80 @@ struct CraftWebView: UIViewRepresentable { | ||
| 3253 | 3253 | }; |
| 3254 | 3254 | |
| 3255 | 3255 | var locationWatchCallbacks = new Map(); |
| 3256 | var legacyLocationWatchCallbacks = []; | |
| 3256 | 3257 | var nextLocationWatchId = 0; |
| 3258 | var locationWatchStarted = false; | |
| 3259 | var locationWatchStart = null; | |
| 3260 | var locationWatchGeneration = 0; | |
| 3261 | ||
| 3262 | function hasLocationWatchers() { | |
| 3263 | return locationWatchCallbacks.size > 0 || legacyLocationWatchCallbacks.length > 0; | |
| 3264 | } | |
| 3265 | ||
| 3266 | function startLocationWatch() { | |
| 3267 | if (locationWatchStarted) return Promise.resolve(true); | |
| 3268 | if (locationWatchStart) return locationWatchStart; | |
| 3269 | ||
| 3270 | var generation = locationWatchGeneration; | |
| 3271 | var pending = craft._invoke('watchPosition'); | |
| 3272 | var managed = pending.then(function(result) { | |
| 3273 | if (generation === locationWatchGeneration) { | |
| 3274 | locationWatchStarted = hasLocationWatchers(); | |
| 3275 | } | |
| 3276 | if (locationWatchStart === managed) locationWatchStart = null; | |
| 3277 | return result; | |
| 3278 | }, function(error) { | |
| 3279 | if (generation === locationWatchGeneration) { | |
| 3280 | locationWatchCallbacks.clear(); | |
| 3281 | legacyLocationWatchCallbacks.length = 0; | |
| 3282 | locationWatchStarted = false; | |
| 3283 | } | |
| 3284 | if (locationWatchStart === managed) locationWatchStart = null; | |
| 3285 | throw error; | |
| 3286 | }); | |
| 3287 | locationWatchStart = managed; | |
| 3288 | return managed; | |
| 3289 | } | |
| 3290 | ||
| 3291 | function stopLocationWatchIfUnused() { | |
| 3292 | if (hasLocationWatchers()) return Promise.resolve(true); | |
| 3293 | if (!locationWatchStarted && !locationWatchStart) return Promise.resolve(true); | |
| 3294 | ||
| 3295 | locationWatchGeneration += 1; | |
| 3296 | locationWatchStarted = false; | |
| 3297 | locationWatchStart = null; | |
| 3298 | return craft._invoke('clearWatch'); | |
| 3299 | } | |
| 3300 | ||
| 3257 | 3301 | window.addEventListener('craftLocationUpdate', function(event) { |
| 3258 | 3302 | locationWatchCallbacks.forEach(function(callback) { callback(event.detail); }); |
| 3303 | legacyLocationWatchCallbacks.slice().forEach(function(callback) { callback(event.detail); }); | |
| 3259 | 3304 | }); |
| 3305 | legacyGeolocation.watchPosition = function(callback) { | |
| 3306 | legacyLocationWatchCallbacks.push(callback); | |
| 3307 | return startLocationWatch(); | |
| 3308 | }; | |
| 3309 | legacyGeolocation.clearWatch = function() { | |
| 3310 | legacyLocationWatchCallbacks.length = 0; | |
| 3311 | return stopLocationWatchIfUnused(); | |
| 3312 | }; | |
| 3260 | 3313 | craft.location = { |
| 3261 | 3314 | getCurrentPosition: function(options) { return legacyGeolocation.getCurrentPosition(options || {}); }, |
| 3262 | 3315 | watchPosition: function(callback) { |
| 3263 | 3316 | var id = ++nextLocationWatchId; |
| 3264 | 3317 | locationWatchCallbacks.set(id, callback); |
| 3265 | if (locationWatchCallbacks.size === 1) void craft._invoke('watchPosition'); | |
| 3318 | void startLocationWatch().catch(function() {}); | |
| 3266 | 3319 | return id; |
| 3267 | 3320 | }, |
| 3268 | 3321 | clearWatch: function(id) { |
| 3269 | locationWatchCallbacks.delete(id); | |
| 3270 | if (locationWatchCallbacks.size === 0) void craft._invoke('clearWatch'); | |
| 3322 | if (typeof id === 'undefined') { | |
| 3323 | locationWatchCallbacks.clear(); | |
| 3324 | legacyLocationWatchCallbacks.length = 0; | |
| 3325 | void stopLocationWatchIfUnused().catch(function() {}); | |
| 3326 | return; | |
| 3327 | } | |
| 3328 | if (!locationWatchCallbacks.delete(id)) return; | |
| 3329 | void stopLocationWatchIfUnused().catch(function() {}); | |
| 3271 | 3330 | }, |
| 3272 | 3331 | startRecording: function() { return craft._invoke('startLocationRecording'); }, |
| 3273 | 3332 | pauseRecording: function() { return craft._invoke('pauseLocationRecording'); }, |
| @@ -249,13 +249,13 @@ export interface CraftBridge { | ||
| 249 | 249 | * @param callback - Function called with new position |
| 250 | 250 | * @returns Watch ID |
| 251 | 251 | */ |
| 252 | watchPosition(callback: (position: Position) => void): string; | |
| 252 | watchPosition(callback: (position: Position) => void): number; | |
| 253 | 253 | |
| 254 | 254 | /** |
| 255 | 255 | * Clear position watch |
| 256 | * @param watchId - Watch ID from watchPosition | |
| 256 | * @param watchId - Watch ID from watchPosition; omit to clear every active watch | |
| 257 | 257 | */ |
| 258 | clearWatch(watchId: string): void; | |
| 258 | clearWatch(watchId?: number): void; | |
| 259 | 259 | |
| 260 | 260 | // ==================== Sensors ==================== |
| 261 | 261 | |
| @@ -175,3 +175,24 @@ test "every direct method craft.d.ts declares exists directly on both bridges" { | ||
| 175 | 175 | return error.DeclaredMethodMissingFromBridge; |
| 176 | 176 | } |
| 177 | 177 | } |
| 178 | ||
| 179 | test "the flat location pair uses numeric handles and delegates to the v1 iOS surface" { | |
| 180 | try testing.expect(std.mem.indexOf( | |
| 181 | u8, | |
| 182 | sdk_types, | |
| 183 | "watchPosition(callback: (position: Position) => void): number;", | |
| 184 | ) != null); | |
| 185 | try testing.expect(std.mem.indexOf(u8, sdk_types, "clearWatch(watchId?: number): void;") != null); | |
| 186 | ||
| 187 | const ios = craftObject(ios_spec) orelse return error.IosCraftObjectNotFound; | |
| 188 | try testing.expect(std.mem.indexOf( | |
| 189 | u8, | |
| 190 | ios, | |
| 191 | "watchPosition: function(callback) {\n return this.location.watchPosition(callback);", | |
| 192 | ) != null); | |
| 193 | try testing.expect(std.mem.indexOf( | |
| 194 | u8, | |
| 195 | ios, | |
| 196 | "clearWatch: function(watchId) {\n return this.location.clearWatch(watchId);", | |
| 197 | ) != null); | |
| 198 | } | |