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` 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/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/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 + ) { + 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/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 3540a300bc5d..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,11 +18,13 @@ 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] = [:] - 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] = [:] @@ -45,9 +48,13 @@ 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) + #if os(iOS) + registrar.publish(instance) + #endif FirebaseStorageHostApiSetup.setUp(binaryMessenger: resolvedMessenger, api: instance) } @@ -55,6 +62,53 @@ public final class FLTFirebaseStoragePlugin: NSObject, FlutterPlugin, FirebaseSt result(FlutterMethodNotImplemented) } + #if os(iOS) + public func detachFromEngine(for registrar: FlutterPluginRegistrar) { + cleanupStreams() + } + #endif + + 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() } + } + } + + 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 let firApp = FLTFirebasePlugin.firebaseAppNamed(app.appName)! @@ -387,12 +441,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 } @@ -407,12 +473,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 } @@ -433,14 +511,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..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,6 +20,15 @@ final class TaskStateChannelStreamHandler: NSObject, FlutterStreamHandler { private var failureHandle: String? private var pausedHandle: String? private var progressHandle: String? + 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 @@ -31,55 +40,109 @@ 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() - } - 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), - ]) - } - progressHandle = task.observe(.progress) { snapshot in - events([ - "taskState": 1, // running - "appName": self.storage.app.name, - "snapshot": self.parseTaskSnapshot(snapshot), - ]) + if Thread.isMainThread { + return startListening(events) } - return nil + + var error: FlutterError? + DispatchQueue.main.sync { + error = startListening(events) + } + return error } + /// Clears the sink before removing observers, including during plugin cleanup. func onCancel(withArguments arguments: Any?) -> FlutterError? { - cleanupObservers() + if Thread.isMainThread { + invalidateOnMain() + } else { + DispatchQueue.main.sync { + self.invalidateOnMain() + } + } 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 startListening(_ events: @escaping FlutterEventSink) -> FlutterError? { + invalidateOnMain() + let listener = Listener(events) + self.listener = listener + + successHandle = task.observe(.success) { [weak self] snapshot in + self?.deliver(listener: listener, 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?.deliver(listener: listener, 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?.deliver(listener: listener, 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?.deliver(listener: listener, terminal: false) { handler in + [ + "taskState": 1, // running + "appName": handler.storage.app.name, + "snapshot": handler.parseTaskSnapshot(snapshot), + ] + } + } + return nil + } + + private func deliver( + listener: Listener, + terminal: Bool, + makeEvent: @escaping (TaskStateChannelStreamHandler) -> [String: Any] + ) { + 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)) + listener?.sink = nil + listener = 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] { @@ -151,12 +214,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/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/tool/README.md b/packages/firebase_storage/firebase_storage/tool/README.md new file mode 100644 index 000000000000..d025928dd300 --- /dev/null +++ b/packages/firebase_storage/firebase_storage/tool/README.md @@ -0,0 +1,40 @@ +# Native Storage listener tests + +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. + +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 +firebase emulators:start --only storage --project flutterfire-e2e-tests +``` + +From the Firebase Storage package directory, run: + +```sh +./tool/test_engine_lifecycle.sh +``` + +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/test_engine_lifecycle.sh b/packages/firebase_storage/firebase_storage/tool/test_engine_lifecycle.sh new file mode 100755 index 000000000000..dff3eabae87e --- /dev/null +++ b/packages/firebase_storage/firebase_storage/tool/test_engine_lifecycle.sh @@ -0,0 +1,26 @@ +#!/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" +curl --silent --show-error --max-time 5 --output /dev/null http://127.0.0.1:9199/ +( + cd "$example_root" + flutter build ios --simulator --debug --no-codesign --no-pub \ + --target=integration_test/native_lifecycle_main.dart +) +xcodebuild test \ + -workspace "$example_root/ios/Runner.xcworkspace" \ + -scheme Runner -configuration Debug \ + -destination "platform=iOS Simulator,id=$1" \ + -derivedDataPath "$example_root/build/native-tests" \ + -clonedSourcePackagesDirPath "$example_root/build/ios/SourcePackages" \ + -parallel-testing-enabled NO CODE_SIGNING_ALLOWED=NO \ + "BUILD_DIR=$example_root/build/ios" 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) {