From a11d46df44c2465e7e82770ae5b8a5bda1c1573d Mon Sep 17 00:00:00 2001 From: Hennie Brink Date: Mon, 7 Sep 2026 12:02:32 +0200 Subject: [PATCH 1/6] fix(storage): invalidate Apple task event sinks on cancellation and detach --- .../FLTFirebaseStoragePlugin.swift | 38 ++++- .../TaskStateChannelStreamHandler.swift | 145 +++++++++++++----- 2 files changed, 140 insertions(+), 43 deletions(-) diff --git a/packages/firebase_storage/firebase_storage/ios/firebase_storage/Sources/firebase_storage/FLTFirebaseStoragePlugin.swift b/packages/firebase_storage/firebase_storage/ios/firebase_storage/Sources/firebase_storage/FLTFirebaseStoragePlugin.swift index 3540a300bc5d..17216b8afb26 100644 --- a/packages/firebase_storage/firebase_storage/ios/firebase_storage/Sources/firebase_storage/FLTFirebaseStoragePlugin.swift +++ b/packages/firebase_storage/firebase_storage/ios/firebase_storage/Sources/firebase_storage/FLTFirebaseStoragePlugin.swift @@ -21,7 +21,7 @@ public final class FLTFirebaseStoragePlugin: NSObject, FlutterPlugin, FirebaseSt private var channel: FlutterMethodChannel? private var messenger: FlutterBinaryMessenger? private var eventChannels: [String: FlutterEventChannel] = [:] - private var streamHandlers: [String: FlutterStreamHandler] = [:] + private var streamHandlers: [String: TaskStateChannelStreamHandler] = [:] private var handleToTask: [Int64: AnyObject] = [:] private var handleToPath: [Int64: String] = [:] private var handleToIdentifier: [Int64: String] = [:] @@ -55,6 +55,30 @@ public final class FLTFirebaseStoragePlugin: NSObject, FlutterPlugin, FirebaseSt result(FlutterMethodNotImplemented) } + public func detachFromEngine(for registrar: FlutterPluginRegistrar) { + if Thread.isMainThread { + cleanupStreamsOnMain() + } else { + DispatchQueue.main.sync { + self.cleanupStreamsOnMain() + } + } + } + + private func cleanupStreamsOnMain() { + // Flutter may tear down an engine without delivering onCancel for every + // active event channel. Invalidate handlers first so already queued + // Firebase callbacks cannot reach a detached FlutterEventSink. + for handler in streamHandlers.values { + handler.invalidate() + } + for eventChannel in eventChannels.values { + eventChannel.setStreamHandler(nil) + } + streamHandlers.removeAll() + eventChannels.removeAll() + } + private func storage(app: InternalStorageFirebaseApp) -> Storage { let base = "gs://" + app.bucket let firApp = FLTFirebasePlugin.firebaseAppNamed(app.appName)! @@ -433,14 +457,14 @@ public final class FLTFirebaseStoragePlugin: NSObject, FlutterPlugin, FirebaseSt let channelName = "plugins.flutter.io/firebase_storage/taskEvent/\(uuid)" let channel = FlutterEventChannel(name: channelName, binaryMessenger: messenger!) let storageInstance = Storage.storage(app: FLTFirebasePlugin.firebaseAppNamed(appName)!) - channel.setStreamHandler( - TaskStateChannelStreamHandler( - task: task, - storage: storageInstance, - identifier: channelName - ) + let streamHandler = TaskStateChannelStreamHandler( + task: task, + storage: storageInstance, + identifier: channelName ) + channel.setStreamHandler(streamHandler) eventChannels[channelName] = channel + streamHandlers[channelName] = streamHandler handleToTask[handle] = task as AnyObject handleToPath[handle] = path handleToIdentifier[handle] = channelName diff --git a/packages/firebase_storage/firebase_storage/ios/firebase_storage/Sources/firebase_storage/TaskStateChannelStreamHandler.swift b/packages/firebase_storage/firebase_storage/ios/firebase_storage/Sources/firebase_storage/TaskStateChannelStreamHandler.swift index 21acfcbe6f32..e79d3cd9cdac 100644 --- a/packages/firebase_storage/firebase_storage/ios/firebase_storage/Sources/firebase_storage/TaskStateChannelStreamHandler.swift +++ b/packages/firebase_storage/firebase_storage/ios/firebase_storage/Sources/firebase_storage/TaskStateChannelStreamHandler.swift @@ -20,6 +20,9 @@ final class TaskStateChannelStreamHandler: NSObject, FlutterStreamHandler { private var failureHandle: String? private var pausedHandle: String? private var progressHandle: String? + private var eventSink: FlutterEventSink? + private var generation: UInt64 = 0 + private var isListening = false init(task: StorageObservableTask, storage: Storage, identifier: String) { self.task = task @@ -31,55 +34,125 @@ final class TaskStateChannelStreamHandler: NSObject, FlutterStreamHandler { withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink ) -> FlutterError? { - successHandle = task.observe(.success) { snapshot in - events([ - "taskState": 2, // success - "appName": self.storage.app.name, - "snapshot": self.parseTaskSnapshot(snapshot), - ]) - self.cleanupObservers() + if Thread.isMainThread { + return startListening(events) } - failureHandle = task.observe(.failure) { snapshot in - let err = snapshot.error as NSError? - let errorDict: [String: Any] = self.errorDict(err) - events([ - "taskState": 4, // error (including cancellations as errors per platform contract) - "appName": self.storage.app.name, - "error": errorDict, - ]) - self.cleanupObservers() - } - pausedHandle = task.observe(.pause) { snapshot in - events([ - "taskState": 0, // paused - "appName": self.storage.app.name, - "snapshot": self.parseTaskSnapshot(snapshot), - ]) + + var error: FlutterError? + DispatchQueue.main.sync { + error = startListening(events) } - progressHandle = task.observe(.progress) { snapshot in - events([ - "taskState": 1, // running - "appName": self.storage.app.name, - "snapshot": self.parseTaskSnapshot(snapshot), - ]) + return error + } + + func onCancel(withArguments arguments: Any?) -> FlutterError? { + if Thread.isMainThread { + invalidateOnMain() + } else { + DispatchQueue.main.sync { + self.invalidateOnMain() + } } return nil } - func onCancel(withArguments arguments: Any?) -> FlutterError? { - cleanupObservers() + /// Invalidates queued deliveries before removing Firebase observers. This is + /// also called by the plugin when Flutter detaches, since Flutter does not + /// necessarily invoke onCancel for every active event channel. + func invalidate() { + if Thread.isMainThread { + invalidateOnMain() + } else { + DispatchQueue.main.sync { + self.invalidateOnMain() + } + } + } + + private func startListening(_ events: @escaping FlutterEventSink) -> FlutterError? { + invalidateOnMain() + eventSink = events + isListening = true + let listenGeneration = generation + + successHandle = task.observe(.success) { [weak self] snapshot in + self?.enqueue(generation: listenGeneration, terminal: true) { handler in + [ + "taskState": 2, // success + "appName": handler.storage.app.name, + "snapshot": handler.parseTaskSnapshot(snapshot), + ] + } + } + failureHandle = task.observe(.failure) { [weak self] snapshot in + self?.enqueue(generation: listenGeneration, terminal: true) { handler in + let err = snapshot.error as NSError? + return [ + "taskState": 4, // error (including cancellations as errors per platform contract) + "appName": handler.storage.app.name, + "error": handler.errorDict(err), + ] + } + } + pausedHandle = task.observe(.pause) { [weak self] snapshot in + self?.enqueue(generation: listenGeneration, terminal: false) { handler in + [ + "taskState": 0, // paused + "appName": handler.storage.app.name, + "snapshot": handler.parseTaskSnapshot(snapshot), + ] + } + } + progressHandle = task.observe(.progress) { [weak self] snapshot in + self?.enqueue(generation: listenGeneration, terminal: false) { handler in + [ + "taskState": 1, // running + "appName": handler.storage.app.name, + "snapshot": handler.parseTaskSnapshot(snapshot), + ] + } + } return nil } - private func cleanupObservers() { - if let h = successHandle { task.removeObserver(withHandle: h) } - if let h = failureHandle { task.removeObserver(withHandle: h) } - if let h = pausedHandle { task.removeObserver(withHandle: h) } - if let h = progressHandle { task.removeObserver(withHandle: h) } + private func enqueue( + generation: UInt64, + terminal: Bool, + makeEvent: @escaping (TaskStateChannelStreamHandler) -> [String: Any] + ) { + DispatchQueue.main.async { [weak self] in + guard let self, + self.isListening, + self.generation == generation, + let events = self.eventSink + else { return } + + let event = makeEvent(self) + if terminal { + // Invalidate and remove observers before sending the terminal event so + // callbacks queued by observer removal cannot send another event. + self.invalidateOnMain() + } + events(event) + } + } + + private func invalidateOnMain() { + dispatchPrecondition(condition: .onQueue(.main)) + generation &+= 1 + isListening = false + eventSink = nil + + let handles = [successHandle, failureHandle, pausedHandle, progressHandle] successHandle = nil failureHandle = nil pausedHandle = nil progressHandle = nil + for handle in handles { + if let handle { + task.removeObserver(withHandle: handle) + } + } } private func parseTaskSnapshot(_ snapshot: StorageTaskSnapshot) -> [String: Any] { From 75d5c716f2a622afce53df1ab51f531787db907d Mon Sep 17 00:00:00 2001 From: Hennie Brink Date: Mon, 7 Sep 2026 12:13:12 +0200 Subject: [PATCH 2/6] test(firebase_storage): cover queued Apple task event lifetimes Extract the event dispatcher for deterministic native XCTest coverage of cancellation, relistening, terminal cleanup, main-queue delivery, and sink release. Add SwiftPM test targets and a standalone test runner, apply repository CI formatting, and add the required contributor entry. --- AUTHORS | 1 + .../ios/firebase_storage/Package.swift | 3 +- .../FLTFirebaseStoragePlugin.swift | 48 ++++-- .../TaskEventDispatcher.swift | 40 +++++ .../TaskStateChannelStreamHandler.swift | 60 +++---- .../TaskEventDispatcherTests.swift | 153 ++++++++++++++++++ .../macos/firebase_storage/Package.swift | 3 +- .../TaskEventDispatcher.swift | 1 + .../macos/firebase_storage/Tests | 1 + .../tool/test_native_lifecycle.sh | 25 +++ 10 files changed, 291 insertions(+), 44 deletions(-) create mode 100644 packages/firebase_storage/firebase_storage/ios/firebase_storage/Sources/firebase_storage/TaskEventDispatcher.swift create mode 100644 packages/firebase_storage/firebase_storage/ios/firebase_storage/Tests/firebase_storageTests/TaskEventDispatcherTests.swift create mode 120000 packages/firebase_storage/firebase_storage/macos/firebase_storage/Sources/firebase_storage/TaskEventDispatcher.swift create mode 120000 packages/firebase_storage/firebase_storage/macos/firebase_storage/Tests create mode 100755 packages/firebase_storage/firebase_storage/tool/test_native_lifecycle.sh diff --git a/AUTHORS b/AUTHORS index cfe2577f7420..27fa02db8abe 100644 --- a/AUTHORS +++ b/AUTHORS @@ -67,3 +67,4 @@ Liu Zhisong Ievgenii Kovtun Dinu-Stefan Rusu Marwan Salim Ba Matraf +Hennie Brink diff --git a/packages/firebase_storage/firebase_storage/ios/firebase_storage/Package.swift b/packages/firebase_storage/firebase_storage/ios/firebase_storage/Package.swift index ba9424282e56..02300e4f839d 100644 --- a/packages/firebase_storage/firebase_storage/ios/firebase_storage/Package.swift +++ b/packages/firebase_storage/firebase_storage/ios/firebase_storage/Package.swift @@ -39,6 +39,7 @@ let package = Package( .define("LIBRARY_VERSION", to: "\"\(libraryVersion)\""), .define("LIBRARY_NAME", to: "\"flutter-fire-gcs\""), ] - ) + ), + .testTarget(name: "firebase_storageTests", dependencies: ["firebase_storage"]), ] ) diff --git a/packages/firebase_storage/firebase_storage/ios/firebase_storage/Sources/firebase_storage/FLTFirebaseStoragePlugin.swift b/packages/firebase_storage/firebase_storage/ios/firebase_storage/Sources/firebase_storage/FLTFirebaseStoragePlugin.swift index 17216b8afb26..923375b92896 100644 --- a/packages/firebase_storage/firebase_storage/ios/firebase_storage/Sources/firebase_storage/FLTFirebaseStoragePlugin.swift +++ b/packages/firebase_storage/firebase_storage/ios/firebase_storage/Sources/firebase_storage/FLTFirebaseStoragePlugin.swift @@ -411,12 +411,24 @@ public final class FLTFirebaseStoragePlugin: NSObject, FlutterPlugin, FirebaseSt private func toMeta(_ m: InternalSettableMetadata) -> StorageMetadata { let md = StorageMetadata() - if let v = m.cacheControl { md.cacheControl = v } - if let v = m.contentType { md.contentType = v } - if let v = m.contentDisposition { md.contentDisposition = v } - if let v = m.contentEncoding { md.contentEncoding = v } - if let v = m.contentLanguage { md.contentLanguage = v } - if let v = m.customMetadata { md.customMetadata = v as? [String: String] } + if let v = m.cacheControl { + md.cacheControl = v + } + if let v = m.contentType { + md.contentType = v + } + if let v = m.contentDisposition { + md.contentDisposition = v + } + if let v = m.contentEncoding { + md.contentEncoding = v + } + if let v = m.contentLanguage { + md.contentLanguage = v + } + if let v = m.customMetadata { + md.customMetadata = v as? [String: String] + } return md } @@ -431,12 +443,24 @@ public final class FLTFirebaseStoragePlugin: NSObject, FlutterPlugin, FirebaseSt out["size"] = md.size out["creationTimeMillis"] = Int((md.timeCreated?.timeIntervalSince1970 ?? 0) * 1000) out["updatedTimeMillis"] = Int((md.updated?.timeIntervalSince1970 ?? 0) * 1000) - if let v = md.md5Hash { out["md5Hash"] = v } - if let v = md.cacheControl { out["cacheControl"] = v } - if let v = md.contentDisposition { out["contentDisposition"] = v } - if let v = md.contentEncoding { out["contentEncoding"] = v } - if let v = md.contentLanguage { out["contentLanguage"] = v } - if let v = md.contentType { out["contentType"] = v } + if let v = md.md5Hash { + out["md5Hash"] = v + } + if let v = md.cacheControl { + out["cacheControl"] = v + } + if let v = md.contentDisposition { + out["contentDisposition"] = v + } + if let v = md.contentEncoding { + out["contentEncoding"] = v + } + if let v = md.contentLanguage { + out["contentLanguage"] = v + } + if let v = md.contentType { + out["contentType"] = v + } out["customMetadata"] = md.customMetadata ?? [:] return out } diff --git a/packages/firebase_storage/firebase_storage/ios/firebase_storage/Sources/firebase_storage/TaskEventDispatcher.swift b/packages/firebase_storage/firebase_storage/ios/firebase_storage/Sources/firebase_storage/TaskEventDispatcher.swift new file mode 100644 index 000000000000..7f3a60c67a1d --- /dev/null +++ b/packages/firebase_storage/firebase_storage/ios/firebase_storage/Sources/firebase_storage/TaskEventDispatcher.swift @@ -0,0 +1,40 @@ +// Copyright 2026 The Chromium Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import Foundation + +/// Serializes task events and invalidates deliveries queued by an earlier listener. +final class TaskEventDispatcher { + private var sink: ((Event) -> Void)? + private var generation: UInt64 = 0 + + func listen(_ sink: @escaping (Event) -> Void) -> UInt64 { + invalidate() + self.sink = sink + return generation + } + + func invalidate() { + dispatchPrecondition(condition: .onQueue(.main)) + generation &+= 1 + sink = nil + } + + func enqueue( + generation: UInt64, terminal: Bool, + makeEvent: @escaping () -> Event?, + beforeTerminal: @escaping () -> Void + ) { + DispatchQueue.main.async { [weak self] in + guard let self, self.generation == generation, let sink = self.sink, + let event = makeEvent() + else { return } + if terminal { + self.invalidate() + beforeTerminal() + } + sink(event) + } + } +} diff --git a/packages/firebase_storage/firebase_storage/ios/firebase_storage/Sources/firebase_storage/TaskStateChannelStreamHandler.swift b/packages/firebase_storage/firebase_storage/ios/firebase_storage/Sources/firebase_storage/TaskStateChannelStreamHandler.swift index e79d3cd9cdac..314d7a305be4 100644 --- a/packages/firebase_storage/firebase_storage/ios/firebase_storage/Sources/firebase_storage/TaskStateChannelStreamHandler.swift +++ b/packages/firebase_storage/firebase_storage/ios/firebase_storage/Sources/firebase_storage/TaskStateChannelStreamHandler.swift @@ -20,9 +20,7 @@ final class TaskStateChannelStreamHandler: NSObject, FlutterStreamHandler { private var failureHandle: String? private var pausedHandle: String? private var progressHandle: String? - private var eventSink: FlutterEventSink? - private var generation: UInt64 = 0 - private var isListening = false + private let dispatcher = TaskEventDispatcher<[String: Any]>() init(task: StorageObservableTask, storage: Storage, identifier: String) { self.task = task @@ -71,9 +69,7 @@ final class TaskStateChannelStreamHandler: NSObject, FlutterStreamHandler { private func startListening(_ events: @escaping FlutterEventSink) -> FlutterError? { invalidateOnMain() - eventSink = events - isListening = true - let listenGeneration = generation + let listenGeneration = dispatcher.listen { events($0) } successHandle = task.observe(.success) { [weak self] snapshot in self?.enqueue(generation: listenGeneration, terminal: true) { handler in @@ -120,28 +116,20 @@ final class TaskStateChannelStreamHandler: NSObject, FlutterStreamHandler { terminal: Bool, makeEvent: @escaping (TaskStateChannelStreamHandler) -> [String: Any] ) { - DispatchQueue.main.async { [weak self] in - guard let self, - self.isListening, - self.generation == generation, - let events = self.eventSink - else { return } - - let event = makeEvent(self) - if terminal { - // Invalidate and remove observers before sending the terminal event so - // callbacks queued by observer removal cannot send another event. - self.invalidateOnMain() - } - events(event) - } + dispatcher.enqueue( + generation: generation, terminal: terminal, + makeEvent: { [weak self] in + guard let self else { return nil } + return makeEvent(self) + }, + beforeTerminal: { [weak self] in + self?.invalidateOnMain() + }) } private func invalidateOnMain() { dispatchPrecondition(condition: .onQueue(.main)) - generation &+= 1 - isListening = false - eventSink = nil + dispatcher.invalidate() let handles = [successHandle, failureHandle, pausedHandle, progressHandle] successHandle = nil @@ -224,12 +212,24 @@ final class TaskStateChannelStreamHandler: NSObject, FlutterStreamHandler { out["size"] = md.size out["creationTimeMillis"] = Int((md.timeCreated?.timeIntervalSince1970 ?? 0) * 1000) out["updatedTimeMillis"] = Int((md.updated?.timeIntervalSince1970 ?? 0) * 1000) - if let v = md.md5Hash { out["md5Hash"] = v } - if let v = md.cacheControl { out["cacheControl"] = v } - if let v = md.contentDisposition { out["contentDisposition"] = v } - if let v = md.contentEncoding { out["contentEncoding"] = v } - if let v = md.contentLanguage { out["contentLanguage"] = v } - if let v = md.contentType { out["contentType"] = v } + if let v = md.md5Hash { + out["md5Hash"] = v + } + if let v = md.cacheControl { + out["cacheControl"] = v + } + if let v = md.contentDisposition { + out["contentDisposition"] = v + } + if let v = md.contentEncoding { + out["contentEncoding"] = v + } + if let v = md.contentLanguage { + out["contentLanguage"] = v + } + if let v = md.contentType { + out["contentType"] = v + } out["customMetadata"] = md.customMetadata ?? [:] return out } diff --git a/packages/firebase_storage/firebase_storage/ios/firebase_storage/Tests/firebase_storageTests/TaskEventDispatcherTests.swift b/packages/firebase_storage/firebase_storage/ios/firebase_storage/Tests/firebase_storageTests/TaskEventDispatcherTests.swift new file mode 100644 index 000000000000..6b4033bb532e --- /dev/null +++ b/packages/firebase_storage/firebase_storage/ios/firebase_storage/Tests/firebase_storageTests/TaskEventDispatcherTests.swift @@ -0,0 +1,153 @@ +// Copyright 2026 The Chromium Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import Foundation +import XCTest + +@testable import firebase_storage + +final class TaskEventDispatcherTests: XCTestCase { + private func onMain(_ body: @escaping () -> Void) { + if Thread.isMainThread { + body() + } else { + DispatchQueue.main.sync(execute: body) + } + } + + private func drainMainQueue() { + let drained = expectation(description: "Queued deliveries drained") + DispatchQueue.main.async { drained.fulfill() } + wait(for: [drained], timeout: 5) + } + + func testInvalidationDropsAlreadyQueuedEvents() { + let dispatcher = TaskEventDispatcher() + var events = [Int]() + onMain { + let generation = dispatcher.listen { events.append($0) } + dispatcher.enqueue( + generation: generation, + terminal: false, + makeEvent: { 1 }, + beforeTerminal: {} + ) + dispatcher.invalidate() + } + drainMainQueue() + XCTAssertTrue(events.isEmpty) + } + + func testRelistenDoesNotReceiveAnEarlierListenersEvents() { + let dispatcher = TaskEventDispatcher() + var oldEvents = [Int]() + var newEvents = [Int]() + onMain { + let oldGeneration = dispatcher.listen { oldEvents.append($0) } + dispatcher.enqueue( + generation: oldGeneration, + terminal: false, + makeEvent: { 1 }, + beforeTerminal: {} + ) + let newGeneration = dispatcher.listen { newEvents.append($0) } + dispatcher.enqueue( + generation: oldGeneration, + terminal: false, + makeEvent: { 2 }, + beforeTerminal: {} + ) + dispatcher.enqueue( + generation: newGeneration, + terminal: false, + makeEvent: { 3 }, + beforeTerminal: {} + ) + } + drainMainQueue() + XCTAssertTrue(oldEvents.isEmpty) + XCTAssertEqual(newEvents, [3]) + } + + func testTerminalDeliveryCleansUpFirstAndDropsFollowingEvents() { + let dispatcher = TaskEventDispatcher() + var events = [Int]() + var cleanedUp = false + onMain { + let generation = dispatcher.listen { + if $0 == 2 { + XCTAssertTrue(cleanedUp) + } + events.append($0) + } + dispatcher.enqueue( + generation: generation, + terminal: false, + makeEvent: { 1 }, + beforeTerminal: {} + ) + dispatcher.enqueue( + generation: generation, terminal: true, makeEvent: { 2 }, + beforeTerminal: { + cleanedUp = true + }) + dispatcher.enqueue( + generation: generation, + terminal: false, + makeEvent: { 3 }, + beforeTerminal: {} + ) + dispatcher.enqueue( + generation: generation, terminal: true, makeEvent: { 4 }, + beforeTerminal: { + XCTFail("Terminal cleanup must run only once") + }) + } + drainMainQueue() + XCTAssertEqual(events, [1, 2]) + } + + func testBackgroundCallbacksAreDeliveredOnMain() { + let dispatcher = TaskEventDispatcher() + let delivered = expectation(description: "Event delivered") + var generation: UInt64 = 0 + onMain { + generation = dispatcher.listen { event in + XCTAssertTrue(Thread.isMainThread) + XCTAssertEqual(event, 42) + delivered.fulfill() + } + } + let listenerGeneration = generation + DispatchQueue.global().async { + dispatcher.enqueue( + generation: listenerGeneration, + terminal: false, + makeEvent: { 42 }, + beforeTerminal: {} + ) + } + wait(for: [delivered], timeout: 5) + } + + func testInvalidationReleasesSinkAndDoesNotBuildStaleEvents() { + final class Owner {} + let dispatcher = TaskEventDispatcher() + weak var owner: Owner? + onMain { + let capturedOwner = Owner() + owner = capturedOwner + let generation = dispatcher.listen { [capturedOwner] _ in _ = capturedOwner } + dispatcher.enqueue( + generation: generation, terminal: false, + makeEvent: { + XCTFail("Invalidated events should not be constructed") + return 1 + }, beforeTerminal: {}) + dispatcher.invalidate() + } + drainMainQueue() + XCTAssertNil(owner) + } +} diff --git a/packages/firebase_storage/firebase_storage/macos/firebase_storage/Package.swift b/packages/firebase_storage/firebase_storage/macos/firebase_storage/Package.swift index 93d624e97d90..1f568f52488f 100644 --- a/packages/firebase_storage/firebase_storage/macos/firebase_storage/Package.swift +++ b/packages/firebase_storage/firebase_storage/macos/firebase_storage/Package.swift @@ -39,6 +39,7 @@ let package = Package( .define("LIBRARY_VERSION", to: "\"\(libraryVersion)\""), .define("LIBRARY_NAME", to: "\"flutter-fire-gcs\""), ] - ) + ), + .testTarget(name: "firebase_storageTests", dependencies: ["firebase_storage"]), ] ) diff --git a/packages/firebase_storage/firebase_storage/macos/firebase_storage/Sources/firebase_storage/TaskEventDispatcher.swift b/packages/firebase_storage/firebase_storage/macos/firebase_storage/Sources/firebase_storage/TaskEventDispatcher.swift new file mode 120000 index 000000000000..15323ee52fff --- /dev/null +++ b/packages/firebase_storage/firebase_storage/macos/firebase_storage/Sources/firebase_storage/TaskEventDispatcher.swift @@ -0,0 +1 @@ +../../../../ios/firebase_storage/Sources/firebase_storage/TaskEventDispatcher.swift \ No newline at end of file diff --git a/packages/firebase_storage/firebase_storage/macos/firebase_storage/Tests b/packages/firebase_storage/firebase_storage/macos/firebase_storage/Tests new file mode 120000 index 000000000000..9e362ebc051e --- /dev/null +++ b/packages/firebase_storage/firebase_storage/macos/firebase_storage/Tests @@ -0,0 +1 @@ +../../ios/firebase_storage/Tests \ No newline at end of file diff --git a/packages/firebase_storage/firebase_storage/tool/test_native_lifecycle.sh b/packages/firebase_storage/firebase_storage/tool/test_native_lifecycle.sh new file mode 100755 index 000000000000..82c8ec66e6d7 --- /dev/null +++ b/packages/firebase_storage/firebase_storage/tool/test_native_lifecycle.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Copyright 2026 The Chromium Authors. +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file. +set -euo pipefail + +# Test the Foundation-only dispatcher without a generated Flutter framework +# or a running Firebase emulator. Full plugin integration is tested separately. +package_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +test_root="$(mktemp -d)" +trap 'rm -rf "$test_root"' EXIT +mkdir -p "$test_root/Sources/firebase_storage" "$test_root/Tests/firebase_storageTests" +cp "$package_root/ios/firebase_storage/Sources/firebase_storage/TaskEventDispatcher.swift" \ + "$test_root/Sources/firebase_storage/" +cp "$package_root/ios/firebase_storage/Tests/firebase_storageTests/TaskEventDispatcherTests.swift" \ + "$test_root/Tests/firebase_storageTests/" +cat > "$test_root/Package.swift" <<'SWIFT' +// swift-tools-version: 5.9 +import PackageDescription +let package = Package(name: "StorageLifecycleTests", targets: [ + .target(name: "firebase_storage"), + .testTarget(name: "firebase_storageTests", dependencies: ["firebase_storage"]), +]) +SWIFT +swift test --package-path "$test_root" From c0a27a7397e28564577e56007273ffc9977557b7 Mon Sep 17 00:00:00 2001 From: Hennie Brink Date: Mon, 7 Sep 2026 12:17:02 +0200 Subject: [PATCH 3/6] fix(firebase_storage): publish plugin for engine detach callbacks Flutter delivers detachFromEngine to published plugin instances. Register the instance with publish so handler invalidation actually runs during engine deallocation. --- .../Sources/firebase_storage/FLTFirebaseStoragePlugin.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/firebase_storage/firebase_storage/ios/firebase_storage/Sources/firebase_storage/FLTFirebaseStoragePlugin.swift b/packages/firebase_storage/firebase_storage/ios/firebase_storage/Sources/firebase_storage/FLTFirebaseStoragePlugin.swift index 923375b92896..ecaa52a4564d 100644 --- a/packages/firebase_storage/firebase_storage/ios/firebase_storage/Sources/firebase_storage/FLTFirebaseStoragePlugin.swift +++ b/packages/firebase_storage/firebase_storage/ios/firebase_storage/Sources/firebase_storage/FLTFirebaseStoragePlugin.swift @@ -48,6 +48,7 @@ public final class FLTFirebaseStoragePlugin: NSObject, FlutterPlugin, FirebaseSt instance.channel = channel instance.messenger = resolvedMessenger registrar.addMethodCallDelegate(instance, channel: channel) + registrar.publish(instance) FirebaseStorageHostApiSetup.setUp(binaryMessenger: resolvedMessenger, api: instance) } From 846a459a9ae91fe3f94dbb8994810a924404c5a9 Mon Sep 17 00:00:00 2001 From: Hennie Brink Date: Mon, 7 Sep 2026 14:07:22 +0200 Subject: [PATCH 4/6] test(firebase_storage): cover real iOS engine disposal --- .../integration_test/instance_e2e.dart | 4 - .../native_lifecycle_main.dart | 7 ++ .../example/integration_test/test_utils.dart | 12 +-- .../StorageEngineLifecycleTests.swift | 93 +++++++++++++++++++ .../FLTFirebaseStoragePlugin.swift | 45 +++++---- .../firebase_storage/tool/README.md | 37 ++++++++ .../tool/configure_engine_lifecycle_test.rb | 39 ++++++++ .../tool/test_engine_lifecycle.sh | 45 +++++++++ 8 files changed, 252 insertions(+), 30 deletions(-) create mode 100644 packages/firebase_storage/firebase_storage/example/integration_test/native_lifecycle_main.dart create mode 100644 packages/firebase_storage/firebase_storage/example/ios/RunnerTests/StorageEngineLifecycleTests.swift create mode 100644 packages/firebase_storage/firebase_storage/tool/README.md create mode 100644 packages/firebase_storage/firebase_storage/tool/configure_engine_lifecycle_test.rb create mode 100755 packages/firebase_storage/firebase_storage/tool/test_engine_lifecycle.sh diff --git a/packages/firebase_storage/firebase_storage/example/integration_test/instance_e2e.dart b/packages/firebase_storage/firebase_storage/example/integration_test/instance_e2e.dart index c95803302aae..5d83c584a1ce 100644 --- a/packages/firebase_storage/firebase_storage/example/integration_test/instance_e2e.dart +++ b/packages/firebase_storage/firebase_storage/example/integration_test/instance_e2e.dart @@ -5,7 +5,6 @@ import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_storage/firebase_storage.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:firebase_storage_example/firebase_options.dart'; import 'test_utils.dart'; @@ -16,9 +15,6 @@ void setupInstanceTests() { late FirebaseApp secondaryAppWithoutBucket; setUpAll(() async { - await Firebase.initializeApp( - options: DefaultFirebaseOptions.currentPlatform, - ); storage = FirebaseStorage.instance; secondaryApp = await testInitializeSecondaryApp(); }); diff --git a/packages/firebase_storage/firebase_storage/example/integration_test/native_lifecycle_main.dart b/packages/firebase_storage/firebase_storage/example/integration_test/native_lifecycle_main.dart new file mode 100644 index 000000000000..dda67b77b416 --- /dev/null +++ b/packages/firebase_storage/firebase_storage/example/integration_test/native_lifecycle_main.dart @@ -0,0 +1,7 @@ +// Copyright 2026 The Chromium Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// The native XCTest host and its secondary engine need a Dart isolate without +// initializing Firebase or running the example UI. +void main() {} diff --git a/packages/firebase_storage/firebase_storage/example/integration_test/test_utils.dart b/packages/firebase_storage/firebase_storage/example/integration_test/test_utils.dart index 819e4af1afed..e0e254a2baf0 100644 --- a/packages/firebase_storage/firebase_storage/example/integration_test/test_utils.dart +++ b/packages/firebase_storage/firebase_storage/example/integration_test/test_utils.dart @@ -100,13 +100,13 @@ Future testInitializeSecondaryApp({ (defaultTargetPlatform == TargetPlatform.macOS || defaultTargetPlatform == TargetPlatform.iOS || defaultTargetPlatform == TargetPlatform.windows)) { + final nativeOptions = Firebase.app().options; testAppOptions = FirebaseOptions( - appId: DefaultFirebaseOptions.currentPlatform.appId, - apiKey: DefaultFirebaseOptions.currentPlatform.apiKey, - projectId: DefaultFirebaseOptions.currentPlatform.projectId, - messagingSenderId: - DefaultFirebaseOptions.currentPlatform.messagingSenderId, - iosBundleId: DefaultFirebaseOptions.currentPlatform.iosBundleId, + appId: nativeOptions.appId, + apiKey: nativeOptions.apiKey, + projectId: nativeOptions.projectId, + messagingSenderId: nativeOptions.messagingSenderId, + iosBundleId: nativeOptions.iosBundleId, storageBucket: withDefaultBucket ? kTestStorageBucket : null, ); } else { diff --git a/packages/firebase_storage/firebase_storage/example/ios/RunnerTests/StorageEngineLifecycleTests.swift b/packages/firebase_storage/firebase_storage/example/ios/RunnerTests/StorageEngineLifecycleTests.swift new file mode 100644 index 000000000000..b1a54212262c --- /dev/null +++ b/packages/firebase_storage/firebase_storage/example/ios/RunnerTests/StorageEngineLifecycleTests.swift @@ -0,0 +1,93 @@ +// Copyright 2026 The Chromium Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import FirebaseCore +import FirebaseStorage +import Flutter +import XCTest + +@testable import firebase_storage + +final class StorageEngineLifecycleTests: XCTestCase { + func testEngineDisposalInvalidatesQueuedTaskEvents() throws { + XCTAssertTrue(Thread.isMainThread) + var plugin: FLTFirebaseStoragePlugin! + var handler: TaskStateChannelStreamHandler! + var task: StorageUploadTask! + weak var releasedEngine: FlutterEngine? + var events = 0 + + try autoreleasepool { + let engine = FlutterEngine( + name: "storage-lifecycle", project: nil, allowHeadlessExecution: true) + releasedEngine = engine + XCTAssertTrue(engine.run()) + FLTFirebaseStoragePlugin.register(with: engine.registrar(forPlugin: "storage-lifecycle")!) + plugin = try XCTUnwrap( + engine.valuePublished(byPlugin: "storage-lifecycle") as? FLTFirebaseStoragePlugin) + (task, handler) = try pausedTask(plugin: plugin) + XCTAssertNil(handler.onListen(withArguments: nil) { _ in events += 1 }) + task.snapshot.reference.storage.callbackQueue.sync {} + XCTAssertEqual(events, 0) + engine.destroyContext() + } + + XCTAssertNil(releasedEngine, "The real engine must dispose before queued events drain") + let handlers: [String: TaskStateChannelStreamHandler] = try field(plugin!, "streamHandlers") + let channels: [String: FlutterEventChannel] = try field(plugin!, "eventChannels") + XCTAssertTrue( + handlers.isEmpty, "Engine disposal must invoke the published plugin's detach callback") + XCTAssertTrue(channels.isEmpty) + drainMainQueue() + XCTAssertEqual(events, 0, "A task callback queued before disposal must not reach its sink") + task.cancel() + withExtendedLifetime(handler) {} + withExtendedLifetime(plugin) {} + } + + private func pausedTask(plugin: FLTFirebaseStoragePlugin) throws -> ( + StorageUploadTask, TaskStateChannelStreamHandler + ) { + let name = "storage-lifecycle-\(UUID().uuidString)" + let options = FirebaseOptions( + googleAppID: "1:123456789012:ios:0000000000000000000000", gcmSenderID: "123456789012") + options.apiKey = "A00000000000000000000000000000000000000" + options.projectID = "flutterfire-e2e-tests" + options.storageBucket = "flutterfire-e2e-tests.appspot.com" + FirebaseApp.configure(name: name, options: options) + let app = try XCTUnwrap(FirebaseApp.app(name: name)) + let storage = Storage.storage(app: app) + storage.useEmulator(withHost: "127.0.0.1", port: 9199) + storage.callbackQueue = DispatchQueue(label: "storage-lifecycle-callbacks") + let reference = InternalStorageReference( + bucket: options.storageBucket!, fullPath: "flutter-tests/\(name)", name: name) + plugin.referencePutData( + app: InternalStorageFirebaseApp(appName: name, tenantId: nil, bucket: options.storageBucket!), + reference: reference, + data: FlutterStandardTypedData(bytes: Data(repeating: 1, count: 16 * 1024 * 1024)), + settableMetaData: InternalSettableMetadata(), handle: 1 + ) { result in + if case .failure(let error) = result { XCTFail("Failed to register upload: \(error)") } + } + let tasks: [Int64: AnyObject] = try field(plugin, "handleToTask") + let task = try XCTUnwrap(tasks[1] as? StorageUploadTask) + let paused = DispatchSemaphore(value: 0) + let observer = task.observe(.pause) { _ in paused.signal() } + task.pause() + XCTAssertEqual(paused.wait(timeout: .now() + 10), .success) + task.removeObserver(withHandle: observer) + let handlers: [String: TaskStateChannelStreamHandler] = try field(plugin, "streamHandlers") + return (task, try XCTUnwrap(handlers.values.first)) + } + + private func field(_ object: Any, _ name: String) throws -> T { + try XCTUnwrap(Mirror(reflecting: object).children.first { $0.label == name }?.value as? T) + } + + private func drainMainQueue() { + let drained = expectation(description: "queued task deliveries drained") + DispatchQueue.main.async { drained.fulfill() } + wait(for: [drained], timeout: 10) + } +} diff --git a/packages/firebase_storage/firebase_storage/ios/firebase_storage/Sources/firebase_storage/FLTFirebaseStoragePlugin.swift b/packages/firebase_storage/firebase_storage/ios/firebase_storage/Sources/firebase_storage/FLTFirebaseStoragePlugin.swift index ecaa52a4564d..c0c3fc7078d4 100644 --- a/packages/firebase_storage/firebase_storage/ios/firebase_storage/Sources/firebase_storage/FLTFirebaseStoragePlugin.swift +++ b/packages/firebase_storage/firebase_storage/ios/firebase_storage/Sources/firebase_storage/FLTFirebaseStoragePlugin.swift @@ -48,7 +48,9 @@ public final class FLTFirebaseStoragePlugin: NSObject, FlutterPlugin, FirebaseSt instance.channel = channel instance.messenger = resolvedMessenger registrar.addMethodCallDelegate(instance, channel: channel) - registrar.publish(instance) + #if os(iOS) + registrar.publish(instance) + #endif FirebaseStorageHostApiSetup.setUp(binaryMessenger: resolvedMessenger, api: instance) } @@ -56,29 +58,32 @@ public final class FLTFirebaseStoragePlugin: NSObject, FlutterPlugin, FirebaseSt result(FlutterMethodNotImplemented) } - public func detachFromEngine(for registrar: FlutterPluginRegistrar) { - if Thread.isMainThread { - cleanupStreamsOnMain() - } else { - DispatchQueue.main.sync { - self.cleanupStreamsOnMain() + #if os(iOS) + public func detachFromEngine(for registrar: FlutterPluginRegistrar) { + if Thread.isMainThread { + cleanupStreamsOnMain() + } else { + DispatchQueue.main.sync { + self.cleanupStreamsOnMain() + } } } - } - private func cleanupStreamsOnMain() { - // Flutter may tear down an engine without delivering onCancel for every - // active event channel. Invalidate handlers first so already queued - // Firebase callbacks cannot reach a detached FlutterEventSink. - for handler in streamHandlers.values { - handler.invalidate() - } - for eventChannel in eventChannels.values { - eventChannel.setStreamHandler(nil) + private func cleanupStreamsOnMain() { + // Flutter may tear down an engine without delivering onCancel for every + // active event channel. Invalidate handlers first so already queued + // Firebase callbacks cannot reach a detached FlutterEventSink. + for handler in streamHandlers.values { + handler.invalidate() + } + for eventChannel in eventChannels.values { + eventChannel.setStreamHandler(nil) + } + streamHandlers.removeAll() + eventChannels.removeAll() } - streamHandlers.removeAll() - eventChannels.removeAll() - } + + #endif private func storage(app: InternalStorageFirebaseApp) -> Storage { let base = "gs://" + app.bucket diff --git a/packages/firebase_storage/firebase_storage/tool/README.md b/packages/firebase_storage/firebase_storage/tool/README.md new file mode 100644 index 000000000000..3035a46f6379 --- /dev/null +++ b/packages/firebase_storage/firebase_storage/tool/README.md @@ -0,0 +1,37 @@ +# Native lifecycle tests + +Run the deterministic Foundation-only dispatcher tests with: + +```sh +./tool/test_native_lifecycle.sh +``` + +To test real iOS engine disposal, first run `melos bootstrap` at the repository +root, start the Storage emulator, and boot an iOS simulator: + +```sh +cd .github/workflows/scripts +firebase emulators:start --only storage --project flutterfire-e2e-tests +``` + +From the Firebase Storage package directory, run: + +```sh +./tool/test_engine_lifecycle.sh +``` + +This requires Xcode, Flutter with Swift Package Manager enabled, CocoaPods' +`xcodeproj` Ruby gem, and the Storage emulator on `127.0.0.1:9199`. The runner builds a minimal Dart entrypoint, creates +a temporary XCTest target in the example project, and restores the project +files afterward. Do not edit or build that example concurrently with the runner. + +The XCTest starts a real headless Flutter engine, registers the actual Storage +plugin, and pauses a real Firebase upload. It queues a real Firebase observer +callback before disposing the engine and verifies that Flutter invokes plugin +cleanup and the queued callback does not reach its old event sink. + +This covers iOS engine disposal, which invokes `detachFromEngine(for:)`. +`destroyContext()` alone while retaining the engine is a different lifecycle +and is not covered by this hook. macOS does not expose the corresponding plugin +detach callback; shared dispatcher tests and the macOS emulator suite cover +cancellation and terminal delivery there. diff --git a/packages/firebase_storage/firebase_storage/tool/configure_engine_lifecycle_test.rb b/packages/firebase_storage/firebase_storage/tool/configure_engine_lifecycle_test.rb new file mode 100644 index 000000000000..3d8469bd13e6 --- /dev/null +++ b/packages/firebase_storage/firebase_storage/tool/configure_engine_lifecycle_test.rb @@ -0,0 +1,39 @@ +# Copyright 2026 The Chromium Authors. +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file. + +require 'xcodeproj' +path = File.join(ARGV.fetch(0), 'Runner.xcodeproj') +project = Xcodeproj::Project.open(path) +runner = project.targets.find { |t| t.name == 'Runner' } +test = project.new_target(:unit_test_bundle, 'StorageLifecycleTests', :ios, '15.0') +test.add_dependency(runner) +group = project.main_group.new_group('RunnerTests', 'RunnerTests') +test.add_file_references([group.new_file('StorageEngineLifecycleTests.swift')]) +test.build_configurations.each do |config| + config.build_settings.merge!({ + 'SWIFT_VERSION' => '5.0', + 'PRODUCT_NAME' => '$(TARGET_NAME)', + 'PRODUCT_BUNDLE_IDENTIFIER' => 'io.flutter.plugins.firebase.storage.lifecycle-tests', + 'GENERATE_INFOPLIST_FILE' => 'YES', + 'TEST_HOST' => '$(BUILT_PRODUCTS_DIR)/Runner.app/Runner', + 'BUNDLE_LOADER' => '$(TEST_HOST)', + 'FRAMEWORK_SEARCH_PATHS' => ['$(inherited)', '$(BUILT_PRODUCTS_DIR)'], + 'SWIFT_INCLUDE_PATHS' => ['$(inherited)', '$(BUILT_PRODUCTS_DIR)'], + 'LD_RUNPATH_SEARCH_PATHS' => ['$(inherited)', '@executable_path/Frameworks', '@loader_path/Frameworks'], + 'CODE_SIGNING_ALLOWED' => 'NO', + }) +end +runner.package_product_dependencies.each do |dep| + added = project.new(Xcodeproj::Project::Object::XCSwiftPackageProductDependency) + added.product_name = dep.product_name + added.package = dep.package + test.package_product_dependencies << added + buildfile = project.new(Xcodeproj::Project::Object::PBXBuildFile) + buildfile.product_ref = added + test.frameworks_build_phase.files << buildfile +end +scheme = Xcodeproj::XCScheme.new +scheme.configure_with_targets(runner, test) +scheme.save_as(path, 'StorageLifecycleTests', true) +project.save diff --git a/packages/firebase_storage/firebase_storage/tool/test_engine_lifecycle.sh b/packages/firebase_storage/firebase_storage/tool/test_engine_lifecycle.sh new file mode 100755 index 000000000000..d7af699f8ded --- /dev/null +++ b/packages/firebase_storage/firebase_storage/tool/test_engine_lifecycle.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Copyright 2026 The Chromium Authors. +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file. +set -euo pipefail + +if [[ $# -ne 1 ]]; then + echo "Usage: $0 " >&2 + exit 64 +fi +package_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +example_root="$package_root/example" +project="$example_root/ios/Runner.xcodeproj" +scheme="$project/xcshareddata/xcschemes/StorageLifecycleTests.xcscheme" +if [[ -e "$scheme" ]]; then + echo "A StorageLifecycleTests scheme already exists; refusing to replace it." >&2 + exit 1 +fi +ruby -e 'require "xcodeproj"' +curl --silent --show-error --max-time 5 --output /dev/null http://127.0.0.1:9199/ +backup="$(mktemp -d)" +cp "$project/project.pbxproj" "$backup/project.pbxproj" +cp "$example_root/ios/Podfile" "$backup/Podfile" +cp "$project/xcshareddata/xcschemes/Runner.xcscheme" "$backup/Runner.xcscheme" +restore_project() { + cp "$backup/project.pbxproj" "$project/project.pbxproj" + cp "$backup/Podfile" "$example_root/ios/Podfile" + cp "$backup/Runner.xcscheme" "$project/xcshareddata/xcschemes/Runner.xcscheme" + rm -f "$scheme" + rm -rf "$backup" +} +trap restore_project EXIT +( + cd "$example_root" + flutter build ios --simulator --debug --no-codesign --no-pub \ + --target=integration_test/native_lifecycle_main.dart +) +ruby "$package_root/tool/configure_engine_lifecycle_test.rb" "$example_root/ios" +xcodebuild test \ + -workspace "$example_root/ios/Runner.xcworkspace" \ + -scheme StorageLifecycleTests -configuration Debug \ + -destination "platform=iOS Simulator,id=$1" \ + -derivedDataPath "$example_root/build/ios" \ + -parallel-testing-enabled NO CODE_SIGNING_ALLOWED=NO \ + "FRAMEWORK_SEARCH_PATHS=\$(inherited) $example_root/build/ios/Debug-iphonesimulator" From 30750a5dc82f468148a9d6cc22b6f77cdcc1f289 Mon Sep 17 00:00:00 2001 From: Hennie Brink Date: Tue, 8 Sep 2026 21:46:01 +0200 Subject: [PATCH 5/6] fix(firebase_storage): simplify listener cleanup and cover reinitialization --- .../ios/Runner.xcodeproj/project.pbxproj | 150 ++++++++++++++ .../xcshareddata/xcschemes/Runner.xcscheme | 10 + .../StorageEngineLifecycleTests.swift | 194 ++++++++++++++++++ .../ios/firebase_storage/Package.swift | 3 +- .../Sources/firebase_storage/Constants.swift | 5 + .../FLTFirebaseStoragePlugin.swift | 66 ++++-- .../TaskEventDispatcher.swift | 40 ---- .../TaskStateChannelStreamHandler.swift | 64 +++--- .../TaskEventDispatcherTests.swift | 153 -------------- .../macos/firebase_storage/Package.swift | 3 +- .../Sources/firebase_storage/Constants.swift | 1 + .../TaskEventDispatcher.swift | 1 - .../macos/firebase_storage/Tests | 1 - .../firebase_storage/tool/README.md | 49 ++--- .../tool/configure_engine_lifecycle_test.rb | 39 ---- .../tool/test_engine_lifecycle.sh | 27 +-- .../tool/test_native_lifecycle.sh | 25 --- scripts/generate_versions_spm.dart | 7 +- 18 files changed, 474 insertions(+), 364 deletions(-) create mode 100644 packages/firebase_storage/firebase_storage/ios/firebase_storage/Sources/firebase_storage/Constants.swift delete mode 100644 packages/firebase_storage/firebase_storage/ios/firebase_storage/Sources/firebase_storage/TaskEventDispatcher.swift delete mode 100644 packages/firebase_storage/firebase_storage/ios/firebase_storage/Tests/firebase_storageTests/TaskEventDispatcherTests.swift create mode 120000 packages/firebase_storage/firebase_storage/macos/firebase_storage/Sources/firebase_storage/Constants.swift delete mode 120000 packages/firebase_storage/firebase_storage/macos/firebase_storage/Sources/firebase_storage/TaskEventDispatcher.swift delete mode 120000 packages/firebase_storage/firebase_storage/macos/firebase_storage/Tests delete mode 100644 packages/firebase_storage/firebase_storage/tool/configure_engine_lifecycle_test.rb delete mode 100755 packages/firebase_storage/firebase_storage/tool/test_native_lifecycle.sh diff --git a/packages/firebase_storage/firebase_storage/example/ios/Runner.xcodeproj/project.pbxproj b/packages/firebase_storage/firebase_storage/example/ios/Runner.xcodeproj/project.pbxproj index 3fffed27f781..c4080bbbe69e 100644 --- a/packages/firebase_storage/firebase_storage/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/firebase_storage/firebase_storage/example/ios/Runner.xcodeproj/project.pbxproj @@ -11,6 +11,8 @@ 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 5C6F5A711EC3CCCC008D64B5 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 5C6F5A701EC3CCCC008D64B5 /* GeneratedPluginRegistrant.m */; }; 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; + 78A3AD3F868B71292C69C49E /* StorageEngineLifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 434234C6C66B4B6473EDD774 /* StorageEngineLifecycleTests.swift */; }; + 878C86B9E4279A162569D36E /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 61A721D45D6B6CB986E623EE /* Foundation.framework */; }; 9740EEB41CF90195004384FC /* Debug.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = 9740EEB21CF90195004384FC /* Debug.xcconfig */; }; 9740EEB51CF90195004384FC /* Generated.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = 9740EEB31CF90195004384FC /* Generated.xcconfig */; }; 978B8F6F1D3862AE00F588F7 /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */; }; @@ -21,6 +23,16 @@ D2E2163194A154ACEE1A71C3 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = FFBFFC26A733835824F4674E /* GoogleService-Info.plist */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + ECF50C917D108BEB7D2A1DB5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXCopyFilesBuildPhase section */ 9705A1C41CF9048500538489 /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; @@ -36,9 +48,12 @@ /* Begin PBXFileReference section */ 02CA5B04FD87E7B77B3477E2 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 1EF4EC1DD2C06F51DEE73DD4 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 434234C6C66B4B6473EDD774 /* StorageEngineLifecycleTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = StorageEngineLifecycleTests.swift; sourceTree = ""; }; 5C6F5A6F1EC3CCCC008D64B5 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 5C6F5A701EC3CCCC008D64B5 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 61A721D45D6B6CB986E623EE /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = System/Library/Frameworks/Foundation.framework; sourceTree = SDKROOT; }; 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; 7A1ECC901E8EDB6900309407 /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; @@ -58,6 +73,14 @@ /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + 5E72461DC15966E2173621C3 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 878C86B9E4279A162569D36E /* Foundation.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 97C146EB1CF9000F007C117D /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -70,6 +93,15 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 263B1D4C1C08199D7D7A9AB8 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 434234C6C66B4B6473EDD774 /* StorageEngineLifecycleTests.swift */, + ); + name = RunnerTests; + path = RunnerTests; + sourceTree = ""; + }; 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( @@ -91,6 +123,7 @@ FFBFFC26A733835824F4674E /* GoogleService-Info.plist */, E6FBE2EBD3E88C317FEF8092 /* Pods */, E437F81FDC10E2D0A1D19577 /* Frameworks */, + 263B1D4C1C08199D7D7A9AB8 /* RunnerTests */, ); sourceTree = ""; }; @@ -98,6 +131,7 @@ isa = PBXGroup; children = ( 97C146EE1CF9000F007C117D /* Runner.app */, + 1EF4EC1DD2C06F51DEE73DD4 /* RunnerTests.xctest */, ); name = Products; sourceTree = ""; @@ -127,10 +161,19 @@ name = "Supporting Files"; sourceTree = ""; }; + BE0258992404267C5412D8B8 /* iOS */ = { + isa = PBXGroup; + children = ( + 61A721D45D6B6CB986E623EE /* Foundation.framework */, + ); + name = iOS; + sourceTree = ""; + }; E437F81FDC10E2D0A1D19577 /* Frameworks */ = { isa = PBXGroup; children = ( B87F8A9D34F2EDB6C0C3EC13 /* Pods_Runner.framework */, + BE0258992404267C5412D8B8 /* iOS */, ); name = Frameworks; sourceTree = ""; @@ -148,6 +191,24 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + 4D8C47C75BBE97BD6C93F104 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 664CF7E91657D4A08391B9F3 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 5819D984F77678DD121ECC5F /* Sources */, + 5E72461DC15966E2173621C3 /* Frameworks */, + 79D0F96BD51041DFC01E5E28 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + E389F46DB397A5BAA37DA657 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 1EF4EC1DD2C06F51DEE73DD4 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; 97C146ED1CF9000F007C117D /* Runner */ = { isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; @@ -205,11 +266,19 @@ projectRoot = ""; targets = ( 97C146ED1CF9000F007C117D /* Runner */, + 4D8C47C75BBE97BD6C93F104 /* RunnerTests */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 79D0F96BD51041DFC01E5E28 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 97C146EC1CF9000F007C117D /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -301,6 +370,14 @@ /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 5819D984F77678DD121ECC5F /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 78A3AD3F868B71292C69C49E /* StorageEngineLifecycleTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 97C146EA1CF9000F007C117D /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -313,6 +390,15 @@ }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + E389F46DB397A5BAA37DA657 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = Runner; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = ECF50C917D108BEB7D2A1DB5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin PBXVariantGroup section */ 97C146FA1CF9000F007C117D /* Main.storyboard */ = { isa = PBXVariantGroup; @@ -333,6 +419,33 @@ /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ + 94802E220EA34C24945115A7 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGNING_ALLOWED = NO; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(BUILT_PRODUCTS_DIR)", + "$(PROJECT_DIR)/../build/ios/Debug-iphonesimulator", + ); + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "io.flutter.plugins.firebase.storage.lifecycle-tests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_INCLUDE_PATHS = "$(inherited) $(BUILT_PRODUCTS_DIR) $(PROJECT_DIR)/../build/ios/Debug-iphonesimulator"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/Runner"; + }; + name = Debug; + }; 97C147031CF9000F007C117D /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -471,9 +584,46 @@ }; name = Release; }; + B3F56AE0CA0B7E9EDC357461 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGNING_ALLOWED = NO; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(BUILT_PRODUCTS_DIR)", + "$(PROJECT_DIR)/../build/ios/Debug-iphonesimulator", + ); + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "io.flutter.plugins.firebase.storage.lifecycle-tests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_INCLUDE_PATHS = "$(inherited) $(BUILT_PRODUCTS_DIR) $(PROJECT_DIR)/../build/ios/Debug-iphonesimulator"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/Runner"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 664CF7E91657D4A08391B9F3 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + B3F56AE0CA0B7E9EDC357461 /* Release */, + 94802E220EA34C24945115A7 /* Debug */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/packages/firebase_storage/firebase_storage/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/packages/firebase_storage/firebase_storage/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index eea484f9dd8b..d35b5b4ae3f3 100755 --- a/packages/firebase_storage/firebase_storage/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/packages/firebase_storage/firebase_storage/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -47,6 +47,16 @@ customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit" shouldUseLaunchSchemeArgsEnv = "YES"> + + + + Void) throws { + let engine = FlutterEngine( + name: "storage-listener-test", project: nil, allowHeadlessExecution: true) + XCTAssertTrue(engine.run()) + defer { engine.destroyContext() } + FLTFirebaseStoragePlugin.register(with: engine.registrar(forPlugin: "storage-listener-test")!) + let plugin = try XCTUnwrap( + engine.valuePublished(byPlugin: "storage-listener-test") as? FLTFirebaseStoragePlugin) + try body(engine, plugin) + withExtendedLifetime(engine) {} + } + + private func assertObserversRemoved( + _ handler: TaskStateChannelStreamHandler, file: StaticString = #filePath, line: UInt = #line + ) { + for name in ["successHandle", "failureHandle", "pausedHandle", "progressHandle"] { + let handle = Mirror(reflecting: handler).children.first { $0.label == name }?.value as? String + XCTAssertNil(handle, file: file, line: line) + } + } + + private func assertPluginMapsEmpty( + _ plugin: FLTFirebaseStoragePlugin, file: StaticString = #filePath, line: UInt = #line + ) { + for name in [ + "streamHandlers", "eventChannels", "handleToTask", "handleToPath", "handleToIdentifier", + ] { + guard let map = Mirror(reflecting: plugin).children.first(where: { $0.label == name }) else { + XCTFail("Missing plugin map \(name)", file: file, line: line) + continue + } + XCTAssertEqual(Mirror(reflecting: map.value).children.count, 0, file: file, line: line) + } + } + private func pausedTask(plugin: FLTFirebaseStoragePlugin) throws -> ( StorageUploadTask, TaskStateChannelStreamHandler ) { diff --git a/packages/firebase_storage/firebase_storage/ios/firebase_storage/Package.swift b/packages/firebase_storage/firebase_storage/ios/firebase_storage/Package.swift index 02300e4f839d..ba9424282e56 100644 --- a/packages/firebase_storage/firebase_storage/ios/firebase_storage/Package.swift +++ b/packages/firebase_storage/firebase_storage/ios/firebase_storage/Package.swift @@ -39,7 +39,6 @@ let package = Package( .define("LIBRARY_VERSION", to: "\"\(libraryVersion)\""), .define("LIBRARY_NAME", to: "\"flutter-fire-gcs\""), ] - ), - .testTarget(name: "firebase_storageTests", dependencies: ["firebase_storage"]), + ) ] ) diff --git a/packages/firebase_storage/firebase_storage/ios/firebase_storage/Sources/firebase_storage/Constants.swift b/packages/firebase_storage/firebase_storage/ios/firebase_storage/Sources/firebase_storage/Constants.swift new file mode 100644 index 000000000000..1343d826da85 --- /dev/null +++ b/packages/firebase_storage/firebase_storage/ios/firebase_storage/Sources/firebase_storage/Constants.swift @@ -0,0 +1,5 @@ +// Copyright 2026 The Chromium Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +public let versionNumber = "13.5.0" diff --git a/packages/firebase_storage/firebase_storage/ios/firebase_storage/Sources/firebase_storage/FLTFirebaseStoragePlugin.swift b/packages/firebase_storage/firebase_storage/ios/firebase_storage/Sources/firebase_storage/FLTFirebaseStoragePlugin.swift index c0c3fc7078d4..fb3761975c92 100644 --- a/packages/firebase_storage/firebase_storage/ios/firebase_storage/Sources/firebase_storage/FLTFirebaseStoragePlugin.swift +++ b/packages/firebase_storage/firebase_storage/ios/firebase_storage/Sources/firebase_storage/FLTFirebaseStoragePlugin.swift @@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import FirebaseCore import FirebaseStorage import Foundation @@ -17,7 +18,9 @@ import Foundation import FlutterMacOS #endif -public final class FLTFirebaseStoragePlugin: NSObject, FlutterPlugin, FirebaseStorageHostApi { +public final class FLTFirebaseStoragePlugin: NSObject, FlutterPlugin, FLTFirebasePluginProtocol, + FirebaseStorageHostApi +{ private var channel: FlutterMethodChannel? private var messenger: FlutterBinaryMessenger? private var eventChannels: [String: FlutterEventChannel] = [:] @@ -45,6 +48,7 @@ public final class FLTFirebaseStoragePlugin: NSObject, FlutterPlugin, FirebaseSt #endif let channel = FlutterMethodChannel(name: channelName, binaryMessenger: resolvedMessenger) let instance = FLTFirebaseStoragePlugin() + FLTFirebasePluginRegistry.sharedInstance().register(instance) instance.channel = channel instance.messenger = resolvedMessenger registrar.addMethodCallDelegate(instance, channel: channel) @@ -60,30 +64,50 @@ public final class FLTFirebaseStoragePlugin: NSObject, FlutterPlugin, FirebaseSt #if os(iOS) public func detachFromEngine(for registrar: FlutterPluginRegistrar) { - if Thread.isMainThread { - cleanupStreamsOnMain() - } else { - DispatchQueue.main.sync { - self.cleanupStreamsOnMain() - } - } + cleanupStreams() } + #endif - private func cleanupStreamsOnMain() { - // Flutter may tear down an engine without delivering onCancel for every - // active event channel. Invalidate handlers first so already queued - // Firebase callbacks cannot reach a detached FlutterEventSink. - for handler in streamHandlers.values { - handler.invalidate() - } - for eventChannel in eventChannels.values { - eventChannel.setStreamHandler(nil) - } - streamHandlers.removeAll() - eventChannels.removeAll() + public func didReinitializeFirebaseCore(_ completion: @escaping () -> Void) { + cleanupStreams() + completion() + } + + public func pluginConstants(for firebaseApp: FirebaseApp) -> [AnyHashable: Any] { + [:] + } + + public func firebaseLibraryName() -> String { "flutter-fire-gcs" } + + public func firebaseLibraryVersion() -> String { versionNumber } + + public func flutterChannelName() -> String { "plugins.flutter.io/firebase_storage" } + + private func cleanupStreams() { + if Thread.isMainThread { + cleanupStreamsOnMain() + } else { + DispatchQueue.main.sync { self.cleanupStreamsOnMain() } } + } - #endif + private func cleanupStreamsOnMain() { + // Removing Firebase observers does not retract callbacks already queued for delivery. + for handler in streamHandlers.values { + _ = handler.onCancel(withArguments: nil) + } + for eventChannel in eventChannels.values { + eventChannel.setStreamHandler(nil) + } + for identifier in handleToIdentifier.values { + Self.canceledIdentifiers.remove(identifier) + } + streamHandlers.removeAll() + eventChannels.removeAll() + handleToTask.removeAll() + handleToPath.removeAll() + handleToIdentifier.removeAll() + } private func storage(app: InternalStorageFirebaseApp) -> Storage { let base = "gs://" + app.bucket diff --git a/packages/firebase_storage/firebase_storage/ios/firebase_storage/Sources/firebase_storage/TaskEventDispatcher.swift b/packages/firebase_storage/firebase_storage/ios/firebase_storage/Sources/firebase_storage/TaskEventDispatcher.swift deleted file mode 100644 index 7f3a60c67a1d..000000000000 --- a/packages/firebase_storage/firebase_storage/ios/firebase_storage/Sources/firebase_storage/TaskEventDispatcher.swift +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright 2026 The Chromium Authors. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -import Foundation - -/// Serializes task events and invalidates deliveries queued by an earlier listener. -final class TaskEventDispatcher { - private var sink: ((Event) -> Void)? - private var generation: UInt64 = 0 - - func listen(_ sink: @escaping (Event) -> Void) -> UInt64 { - invalidate() - self.sink = sink - return generation - } - - func invalidate() { - dispatchPrecondition(condition: .onQueue(.main)) - generation &+= 1 - sink = nil - } - - func enqueue( - generation: UInt64, terminal: Bool, - makeEvent: @escaping () -> Event?, - beforeTerminal: @escaping () -> Void - ) { - DispatchQueue.main.async { [weak self] in - guard let self, self.generation == generation, let sink = self.sink, - let event = makeEvent() - else { return } - if terminal { - self.invalidate() - beforeTerminal() - } - sink(event) - } - } -} diff --git a/packages/firebase_storage/firebase_storage/ios/firebase_storage/Sources/firebase_storage/TaskStateChannelStreamHandler.swift b/packages/firebase_storage/firebase_storage/ios/firebase_storage/Sources/firebase_storage/TaskStateChannelStreamHandler.swift index 314d7a305be4..46866329d0a8 100644 --- a/packages/firebase_storage/firebase_storage/ios/firebase_storage/Sources/firebase_storage/TaskStateChannelStreamHandler.swift +++ b/packages/firebase_storage/firebase_storage/ios/firebase_storage/Sources/firebase_storage/TaskStateChannelStreamHandler.swift @@ -20,7 +20,15 @@ final class TaskStateChannelStreamHandler: NSObject, FlutterStreamHandler { private var failureHandle: String? private var pausedHandle: String? private var progressHandle: String? - private let dispatcher = TaskEventDispatcher<[String: Any]>() + private final class Listener { + var sink: FlutterEventSink? + + init(_ sink: @escaping FlutterEventSink) { + self.sink = sink + } + } + + private var listener: Listener? init(task: StorageObservableTask, storage: Storage, identifier: String) { self.task = task @@ -43,6 +51,7 @@ final class TaskStateChannelStreamHandler: NSObject, FlutterStreamHandler { return error } + /// Clears the sink before removing observers, including during plugin cleanup. func onCancel(withArguments arguments: Any?) -> FlutterError? { if Thread.isMainThread { invalidateOnMain() @@ -54,25 +63,13 @@ final class TaskStateChannelStreamHandler: NSObject, FlutterStreamHandler { return nil } - /// Invalidates queued deliveries before removing Firebase observers. This is - /// also called by the plugin when Flutter detaches, since Flutter does not - /// necessarily invoke onCancel for every active event channel. - func invalidate() { - if Thread.isMainThread { - invalidateOnMain() - } else { - DispatchQueue.main.sync { - self.invalidateOnMain() - } - } - } - private func startListening(_ events: @escaping FlutterEventSink) -> FlutterError? { invalidateOnMain() - let listenGeneration = dispatcher.listen { events($0) } + let listener = Listener(events) + self.listener = listener successHandle = task.observe(.success) { [weak self] snapshot in - self?.enqueue(generation: listenGeneration, terminal: true) { handler in + self?.deliver(listener: listener, terminal: true) { handler in [ "taskState": 2, // success "appName": handler.storage.app.name, @@ -81,7 +78,7 @@ final class TaskStateChannelStreamHandler: NSObject, FlutterStreamHandler { } } failureHandle = task.observe(.failure) { [weak self] snapshot in - self?.enqueue(generation: listenGeneration, terminal: true) { handler in + self?.deliver(listener: listener, terminal: true) { handler in let err = snapshot.error as NSError? return [ "taskState": 4, // error (including cancellations as errors per platform contract) @@ -91,7 +88,7 @@ final class TaskStateChannelStreamHandler: NSObject, FlutterStreamHandler { } } pausedHandle = task.observe(.pause) { [weak self] snapshot in - self?.enqueue(generation: listenGeneration, terminal: false) { handler in + self?.deliver(listener: listener, terminal: false) { handler in [ "taskState": 0, // paused "appName": handler.storage.app.name, @@ -100,7 +97,7 @@ final class TaskStateChannelStreamHandler: NSObject, FlutterStreamHandler { } } progressHandle = task.observe(.progress) { [weak self] snapshot in - self?.enqueue(generation: listenGeneration, terminal: false) { handler in + self?.deliver(listener: listener, terminal: false) { handler in [ "taskState": 1, // running "appName": handler.storage.app.name, @@ -111,25 +108,30 @@ final class TaskStateChannelStreamHandler: NSObject, FlutterStreamHandler { return nil } - private func enqueue( - generation: UInt64, + private func deliver( + listener: Listener, terminal: Bool, makeEvent: @escaping (TaskStateChannelStreamHandler) -> [String: Any] ) { - dispatcher.enqueue( - generation: generation, terminal: terminal, - makeEvent: { [weak self] in - guard let self else { return nil } - return makeEvent(self) - }, - beforeTerminal: { [weak self] in - self?.invalidateOnMain() - }) + let send = { [weak self] in + guard let self, let sink = listener.sink else { return } + let event = makeEvent(self) + if terminal { + self.invalidateOnMain() + } + sink(event) + } + if Thread.isMainThread { + send() + } else { + DispatchQueue.main.async(execute: send) + } } private func invalidateOnMain() { dispatchPrecondition(condition: .onQueue(.main)) - dispatcher.invalidate() + listener?.sink = nil + listener = nil let handles = [successHandle, failureHandle, pausedHandle, progressHandle] successHandle = nil diff --git a/packages/firebase_storage/firebase_storage/ios/firebase_storage/Tests/firebase_storageTests/TaskEventDispatcherTests.swift b/packages/firebase_storage/firebase_storage/ios/firebase_storage/Tests/firebase_storageTests/TaskEventDispatcherTests.swift deleted file mode 100644 index 6b4033bb532e..000000000000 --- a/packages/firebase_storage/firebase_storage/ios/firebase_storage/Tests/firebase_storageTests/TaskEventDispatcherTests.swift +++ /dev/null @@ -1,153 +0,0 @@ -// Copyright 2026 The Chromium Authors. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -import Foundation -import XCTest - -@testable import firebase_storage - -final class TaskEventDispatcherTests: XCTestCase { - private func onMain(_ body: @escaping () -> Void) { - if Thread.isMainThread { - body() - } else { - DispatchQueue.main.sync(execute: body) - } - } - - private func drainMainQueue() { - let drained = expectation(description: "Queued deliveries drained") - DispatchQueue.main.async { drained.fulfill() } - wait(for: [drained], timeout: 5) - } - - func testInvalidationDropsAlreadyQueuedEvents() { - let dispatcher = TaskEventDispatcher() - var events = [Int]() - onMain { - let generation = dispatcher.listen { events.append($0) } - dispatcher.enqueue( - generation: generation, - terminal: false, - makeEvent: { 1 }, - beforeTerminal: {} - ) - dispatcher.invalidate() - } - drainMainQueue() - XCTAssertTrue(events.isEmpty) - } - - func testRelistenDoesNotReceiveAnEarlierListenersEvents() { - let dispatcher = TaskEventDispatcher() - var oldEvents = [Int]() - var newEvents = [Int]() - onMain { - let oldGeneration = dispatcher.listen { oldEvents.append($0) } - dispatcher.enqueue( - generation: oldGeneration, - terminal: false, - makeEvent: { 1 }, - beforeTerminal: {} - ) - let newGeneration = dispatcher.listen { newEvents.append($0) } - dispatcher.enqueue( - generation: oldGeneration, - terminal: false, - makeEvent: { 2 }, - beforeTerminal: {} - ) - dispatcher.enqueue( - generation: newGeneration, - terminal: false, - makeEvent: { 3 }, - beforeTerminal: {} - ) - } - drainMainQueue() - XCTAssertTrue(oldEvents.isEmpty) - XCTAssertEqual(newEvents, [3]) - } - - func testTerminalDeliveryCleansUpFirstAndDropsFollowingEvents() { - let dispatcher = TaskEventDispatcher() - var events = [Int]() - var cleanedUp = false - onMain { - let generation = dispatcher.listen { - if $0 == 2 { - XCTAssertTrue(cleanedUp) - } - events.append($0) - } - dispatcher.enqueue( - generation: generation, - terminal: false, - makeEvent: { 1 }, - beforeTerminal: {} - ) - dispatcher.enqueue( - generation: generation, terminal: true, makeEvent: { 2 }, - beforeTerminal: { - cleanedUp = true - }) - dispatcher.enqueue( - generation: generation, - terminal: false, - makeEvent: { 3 }, - beforeTerminal: {} - ) - dispatcher.enqueue( - generation: generation, terminal: true, makeEvent: { 4 }, - beforeTerminal: { - XCTFail("Terminal cleanup must run only once") - }) - } - drainMainQueue() - XCTAssertEqual(events, [1, 2]) - } - - func testBackgroundCallbacksAreDeliveredOnMain() { - let dispatcher = TaskEventDispatcher() - let delivered = expectation(description: "Event delivered") - var generation: UInt64 = 0 - onMain { - generation = dispatcher.listen { event in - XCTAssertTrue(Thread.isMainThread) - XCTAssertEqual(event, 42) - delivered.fulfill() - } - } - let listenerGeneration = generation - DispatchQueue.global().async { - dispatcher.enqueue( - generation: listenerGeneration, - terminal: false, - makeEvent: { 42 }, - beforeTerminal: {} - ) - } - wait(for: [delivered], timeout: 5) - } - - func testInvalidationReleasesSinkAndDoesNotBuildStaleEvents() { - final class Owner {} - let dispatcher = TaskEventDispatcher() - weak var owner: Owner? - onMain { - let capturedOwner = Owner() - owner = capturedOwner - let generation = dispatcher.listen { [capturedOwner] _ in _ = capturedOwner } - dispatcher.enqueue( - generation: generation, terminal: false, - makeEvent: { - XCTFail("Invalidated events should not be constructed") - return 1 - }, beforeTerminal: {}) - dispatcher.invalidate() - } - drainMainQueue() - XCTAssertNil(owner) - } -} diff --git a/packages/firebase_storage/firebase_storage/macos/firebase_storage/Package.swift b/packages/firebase_storage/firebase_storage/macos/firebase_storage/Package.swift index 1f568f52488f..93d624e97d90 100644 --- a/packages/firebase_storage/firebase_storage/macos/firebase_storage/Package.swift +++ b/packages/firebase_storage/firebase_storage/macos/firebase_storage/Package.swift @@ -39,7 +39,6 @@ let package = Package( .define("LIBRARY_VERSION", to: "\"\(libraryVersion)\""), .define("LIBRARY_NAME", to: "\"flutter-fire-gcs\""), ] - ), - .testTarget(name: "firebase_storageTests", dependencies: ["firebase_storage"]), + ) ] ) diff --git a/packages/firebase_storage/firebase_storage/macos/firebase_storage/Sources/firebase_storage/Constants.swift b/packages/firebase_storage/firebase_storage/macos/firebase_storage/Sources/firebase_storage/Constants.swift new file mode 120000 index 000000000000..137ea9c3e67e --- /dev/null +++ b/packages/firebase_storage/firebase_storage/macos/firebase_storage/Sources/firebase_storage/Constants.swift @@ -0,0 +1 @@ +../../../../ios/firebase_storage/Sources/firebase_storage/Constants.swift \ No newline at end of file diff --git a/packages/firebase_storage/firebase_storage/macos/firebase_storage/Sources/firebase_storage/TaskEventDispatcher.swift b/packages/firebase_storage/firebase_storage/macos/firebase_storage/Sources/firebase_storage/TaskEventDispatcher.swift deleted file mode 120000 index 15323ee52fff..000000000000 --- a/packages/firebase_storage/firebase_storage/macos/firebase_storage/Sources/firebase_storage/TaskEventDispatcher.swift +++ /dev/null @@ -1 +0,0 @@ -../../../../ios/firebase_storage/Sources/firebase_storage/TaskEventDispatcher.swift \ No newline at end of file diff --git a/packages/firebase_storage/firebase_storage/macos/firebase_storage/Tests b/packages/firebase_storage/firebase_storage/macos/firebase_storage/Tests deleted file mode 120000 index 9e362ebc051e..000000000000 --- a/packages/firebase_storage/firebase_storage/macos/firebase_storage/Tests +++ /dev/null @@ -1 +0,0 @@ -../../ios/firebase_storage/Tests \ No newline at end of file diff --git a/packages/firebase_storage/firebase_storage/tool/README.md b/packages/firebase_storage/firebase_storage/tool/README.md index 3035a46f6379..d025928dd300 100644 --- a/packages/firebase_storage/firebase_storage/tool/README.md +++ b/packages/firebase_storage/firebase_storage/tool/README.md @@ -1,13 +1,11 @@ -# Native lifecycle tests +# Native Storage listener tests -Run the deterministic Foundation-only dispatcher tests with: +The example's checked-in `RunnerTests` target exercises the actual Storage +plugin and Firebase SDK on an iOS simulator. It uses the example's normal +`Runner` scheme and links against the host app's plugin code. -```sh -./tool/test_native_lifecycle.sh -``` - -To test real iOS engine disposal, first run `melos bootstrap` at the repository -root, start the Storage emulator, and boot an iOS simulator: +First run `melos bootstrap` at the repository root, enable Flutter Swift Package +Manager support, and start the local Storage emulator: ```sh cd .github/workflows/scripts @@ -20,18 +18,23 @@ From the Firebase Storage package directory, run: ./tool/test_engine_lifecycle.sh ``` -This requires Xcode, Flutter with Swift Package Manager enabled, CocoaPods' -`xcodeproj` Ruby gem, and the Storage emulator on `127.0.0.1:9199`. The runner builds a minimal Dart entrypoint, creates -a temporary XCTest target in the example project, and restores the project -files afterward. Do not edit or build that example concurrently with the runner. - -The XCTest starts a real headless Flutter engine, registers the actual Storage -plugin, and pauses a real Firebase upload. It queues a real Firebase observer -callback before disposing the engine and verifies that Flutter invokes plugin -cleanup and the queued callback does not reach its old event sink. - -This covers iOS engine disposal, which invokes `detachFromEngine(for:)`. -`destroyContext()` alone while retaining the engine is a different lifecycle -and is not covered by this hook. macOS does not expose the corresponding plugin -detach callback; shared dispatcher tests and the macOS emulator suite cover -cancellation and terminal delivery there. +The runner requires Xcode, Flutter, a booted iOS simulator, and the Storage +emulator on `127.0.0.1:9199`. It builds a minimal Dart entrypoint and runs the +checked-in Xcode test target. It does not create or edit test targets or schemes. +Flutter may perform its normal generated-project migrations during the build. + +The tests cover callbacks queued before cancellation, relistening, background +callback delivery, immediate sink release, terminal success and cancellation, +iOS engine disposal, and cleanup through Firebase Core's reinitialization +registry followed by a new successful upload. Fixtures use a real paused upload +and an event-counting sink. No Firebase credentials or production bucket are +required. + +Listener cleanup removes observers and stale plugin task handles. It does not +explicitly cancel native transfers, and it preserves emulator configuration +across Firebase Core reinitialization. + +Engine disposal invokes `detachFromEngine(for:)` on iOS. Calling `destroyContext()` +while retaining the engine does not invoke that hook and remains outside this +fix. macOS has no corresponding plugin-detach callback; cancellation and Firebase +Core reinitialization cleanup use the shared Swift implementation there. diff --git a/packages/firebase_storage/firebase_storage/tool/configure_engine_lifecycle_test.rb b/packages/firebase_storage/firebase_storage/tool/configure_engine_lifecycle_test.rb deleted file mode 100644 index 3d8469bd13e6..000000000000 --- a/packages/firebase_storage/firebase_storage/tool/configure_engine_lifecycle_test.rb +++ /dev/null @@ -1,39 +0,0 @@ -# Copyright 2026 The Chromium Authors. -# Use of this source code is governed by a BSD-style license that can be -# found in the LICENSE file. - -require 'xcodeproj' -path = File.join(ARGV.fetch(0), 'Runner.xcodeproj') -project = Xcodeproj::Project.open(path) -runner = project.targets.find { |t| t.name == 'Runner' } -test = project.new_target(:unit_test_bundle, 'StorageLifecycleTests', :ios, '15.0') -test.add_dependency(runner) -group = project.main_group.new_group('RunnerTests', 'RunnerTests') -test.add_file_references([group.new_file('StorageEngineLifecycleTests.swift')]) -test.build_configurations.each do |config| - config.build_settings.merge!({ - 'SWIFT_VERSION' => '5.0', - 'PRODUCT_NAME' => '$(TARGET_NAME)', - 'PRODUCT_BUNDLE_IDENTIFIER' => 'io.flutter.plugins.firebase.storage.lifecycle-tests', - 'GENERATE_INFOPLIST_FILE' => 'YES', - 'TEST_HOST' => '$(BUILT_PRODUCTS_DIR)/Runner.app/Runner', - 'BUNDLE_LOADER' => '$(TEST_HOST)', - 'FRAMEWORK_SEARCH_PATHS' => ['$(inherited)', '$(BUILT_PRODUCTS_DIR)'], - 'SWIFT_INCLUDE_PATHS' => ['$(inherited)', '$(BUILT_PRODUCTS_DIR)'], - 'LD_RUNPATH_SEARCH_PATHS' => ['$(inherited)', '@executable_path/Frameworks', '@loader_path/Frameworks'], - 'CODE_SIGNING_ALLOWED' => 'NO', - }) -end -runner.package_product_dependencies.each do |dep| - added = project.new(Xcodeproj::Project::Object::XCSwiftPackageProductDependency) - added.product_name = dep.product_name - added.package = dep.package - test.package_product_dependencies << added - buildfile = project.new(Xcodeproj::Project::Object::PBXBuildFile) - buildfile.product_ref = added - test.frameworks_build_phase.files << buildfile -end -scheme = Xcodeproj::XCScheme.new -scheme.configure_with_targets(runner, test) -scheme.save_as(path, 'StorageLifecycleTests', true) -project.save diff --git a/packages/firebase_storage/firebase_storage/tool/test_engine_lifecycle.sh b/packages/firebase_storage/firebase_storage/tool/test_engine_lifecycle.sh index d7af699f8ded..dff3eabae87e 100755 --- a/packages/firebase_storage/firebase_storage/tool/test_engine_lifecycle.sh +++ b/packages/firebase_storage/firebase_storage/tool/test_engine_lifecycle.sh @@ -10,36 +10,17 @@ if [[ $# -ne 1 ]]; then fi package_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" example_root="$package_root/example" -project="$example_root/ios/Runner.xcodeproj" -scheme="$project/xcshareddata/xcschemes/StorageLifecycleTests.xcscheme" -if [[ -e "$scheme" ]]; then - echo "A StorageLifecycleTests scheme already exists; refusing to replace it." >&2 - exit 1 -fi -ruby -e 'require "xcodeproj"' curl --silent --show-error --max-time 5 --output /dev/null http://127.0.0.1:9199/ -backup="$(mktemp -d)" -cp "$project/project.pbxproj" "$backup/project.pbxproj" -cp "$example_root/ios/Podfile" "$backup/Podfile" -cp "$project/xcshareddata/xcschemes/Runner.xcscheme" "$backup/Runner.xcscheme" -restore_project() { - cp "$backup/project.pbxproj" "$project/project.pbxproj" - cp "$backup/Podfile" "$example_root/ios/Podfile" - cp "$backup/Runner.xcscheme" "$project/xcshareddata/xcschemes/Runner.xcscheme" - rm -f "$scheme" - rm -rf "$backup" -} -trap restore_project EXIT ( cd "$example_root" flutter build ios --simulator --debug --no-codesign --no-pub \ --target=integration_test/native_lifecycle_main.dart ) -ruby "$package_root/tool/configure_engine_lifecycle_test.rb" "$example_root/ios" xcodebuild test \ -workspace "$example_root/ios/Runner.xcworkspace" \ - -scheme StorageLifecycleTests -configuration Debug \ + -scheme Runner -configuration Debug \ -destination "platform=iOS Simulator,id=$1" \ - -derivedDataPath "$example_root/build/ios" \ + -derivedDataPath "$example_root/build/native-tests" \ + -clonedSourcePackagesDirPath "$example_root/build/ios/SourcePackages" \ -parallel-testing-enabled NO CODE_SIGNING_ALLOWED=NO \ - "FRAMEWORK_SEARCH_PATHS=\$(inherited) $example_root/build/ios/Debug-iphonesimulator" + "BUILD_DIR=$example_root/build/ios" diff --git a/packages/firebase_storage/firebase_storage/tool/test_native_lifecycle.sh b/packages/firebase_storage/firebase_storage/tool/test_native_lifecycle.sh deleted file mode 100755 index 82c8ec66e6d7..000000000000 --- a/packages/firebase_storage/firebase_storage/tool/test_native_lifecycle.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/usr/bin/env bash -# Copyright 2026 The Chromium Authors. -# Use of this source code is governed by a BSD-style license that can be -# found in the LICENSE file. -set -euo pipefail - -# Test the Foundation-only dispatcher without a generated Flutter framework -# or a running Firebase emulator. Full plugin integration is tested separately. -package_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -test_root="$(mktemp -d)" -trap 'rm -rf "$test_root"' EXIT -mkdir -p "$test_root/Sources/firebase_storage" "$test_root/Tests/firebase_storageTests" -cp "$package_root/ios/firebase_storage/Sources/firebase_storage/TaskEventDispatcher.swift" \ - "$test_root/Sources/firebase_storage/" -cp "$package_root/ios/firebase_storage/Tests/firebase_storageTests/TaskEventDispatcherTests.swift" \ - "$test_root/Tests/firebase_storageTests/" -cat > "$test_root/Package.swift" <<'SWIFT' -// swift-tools-version: 5.9 -import PackageDescription -let package = Package(name: "StorageLifecycleTests", targets: [ - .target(name: "firebase_storage"), - .testTarget(name: "firebase_storageTests", dependencies: ["firebase_storage"]), -]) -SWIFT -swift test --package-path "$test_root" diff --git a/scripts/generate_versions_spm.dart b/scripts/generate_versions_spm.dart index 2b7766dedcdb..2c2c32caab7c 100644 --- a/scripts/generate_versions_spm.dart +++ b/scripts/generate_versions_spm.dart @@ -79,7 +79,7 @@ void updatePluginPackageSwiftVersions( // Update firebaseSdkVersion (matches Package.swift naming). content = content.replaceAll( - RegExp(r'let firebaseSdkVersion: Version = "[^"]+"'), + RegExp('let firebaseSdkVersion: Version = "[^"]+"'), 'let firebaseSdkVersion: Version = "$firebaseiOSVersion"', ); @@ -91,11 +91,11 @@ void updatePluginPackageSwiftVersions( if (version != null) { final spmVersion = version.replaceAll('+', '-'); content = content.replaceAll( - RegExp(r'let libraryVersionString = "[^"]+"'), + RegExp('let libraryVersionString = "[^"]+"'), 'let libraryVersionString = "$spmVersion"', ); content = content.replaceAll( - RegExp(r'let libraryVersion = "[^"]+"'), + RegExp('let libraryVersion = "[^"]+"'), 'let libraryVersion = "$spmVersion"', ); } @@ -118,6 +118,7 @@ void updateLibraryVersionPureSwiftPlugins() { 'firebase_in_app_messaging', 'firebase_crashlytics', 'firebase_core', + 'firebase_storage', ]; for (final package in packages) { From 9abe4985840908cb3e78821e7de31bf3689716d0 Mon Sep 17 00:00:00 2001 From: Hennie Brink Date: Thu, 10 Sep 2026 18:20:42 +0200 Subject: [PATCH 6/6] ci(firebase_storage): run native iOS lifecycle tests --- .github/workflows/e2e_tests_storage.yaml | 1 + .github/workflows/reusable_e2e_changes.yaml | 1 + .github/workflows/reusable_e2e_ios.yaml | 21 +++++++++++++++++++++ 3 files changed, 23 insertions(+) diff --git a/.github/workflows/e2e_tests_storage.yaml b/.github/workflows/e2e_tests_storage.yaml index 8beadfce3a67..89679a90ed44 100644 --- a/.github/workflows/e2e_tests_storage.yaml +++ b/.github/workflows/e2e_tests_storage.yaml @@ -67,6 +67,7 @@ jobs: package-path: 'packages/firebase_storage/firebase_storage' package-scope: 'firebase_storage*' cache-key-suffix: 'storage' + native-test-script: 'tool/test_engine_lifecycle.sh' native-config-args: '--storage-native' storage-emulator-debug: true nightly_test_mode: ${{ inputs.nightly_test_mode == true }} diff --git a/.github/workflows/reusable_e2e_changes.yaml b/.github/workflows/reusable_e2e_changes.yaml index 0477994148d3..90247986d122 100644 --- a/.github/workflows/reusable_e2e_changes.yaml +++ b/.github/workflows/reusable_e2e_changes.yaml @@ -118,6 +118,7 @@ jobs: - '${{ inputs.package-path }}/ios/**' - '${{ inputs.package-path }}/darwin/**' - '${{ inputs.package-path }}/example/ios/**' + - '${{ inputs.package-path }}/tool/test_engine_lifecycle.sh' macos: - *shared - '${{ inputs.package-path }}/macos/**' diff --git a/.github/workflows/reusable_e2e_ios.yaml b/.github/workflows/reusable_e2e_ios.yaml index 79b83ecfc045..3a57f5b4e23e 100644 --- a/.github/workflows/reusable_e2e_ios.yaml +++ b/.github/workflows/reusable_e2e_ios.yaml @@ -26,6 +26,10 @@ on: description: 'Integration test entrypoint, relative to the example directory.' type: string default: 'integration_test/e2e_test.dart' + native-test-script: + description: 'Optional native test script relative to the package; receives the simulator UDID and runs with the Storage emulator.' + type: string + default: '' native-config-args: description: >- Arguments for generate-dummy-firebase-configs.dart, e.g. @@ -270,6 +274,23 @@ jobs: SIMULATOR: ${{ steps.simulator.outputs.udid }} ENSURE_BOOT_IF_NEEDED: "0" run: .github/workflows/scripts/ensure-simulator-ready.sh + - name: Native iOS tests + if: inputs.native-test-script != '' + working-directory: ./.github/workflows/scripts + timeout-minutes: 20 + env: + SIMULATOR: ${{ steps.simulator.outputs.udid }} + NATIVE_TEST_SCRIPT: ${{ inputs.native-test-script }} + run: | + firebase emulators:exec --only storage --project flutterfire-e2e-tests \ + 'bash "${GITHUB_WORKSPACE}/${PACKAGE_PATH}/${NATIVE_TEST_SCRIPT}" "$SIMULATOR"' + - name: Upload native iOS test results + if: ${{ !cancelled() && inputs.native-test-script != '' }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: native-ios-tests-${{ inputs.cache-key-suffix }} + path: ${{ inputs.package-path }}/example/build/native-tests/Logs/Test/*.xcresult + if-no-files-found: warn - name: 'E2E Tests' if: inputs.use-firebase-emulators # firebase.json and the emulator rule files live here, so `emulators:exec`