From ed25b1385a52dea00e7d75ca300d872c31b68308 Mon Sep 17 00:00:00 2001 From: Server Date: Thu, 13 Nov 2025 23:29:03 -0800 Subject: [PATCH] fix(ios): enable Capacitor plugin discovery via CAPBridgedPlugin conformance Capacitor iOS was not discovering DailyNotificationPlugin because it did not conform to the CAPBridgedPlugin protocol required for runtime discovery. Changes: - Add @objc extension to DailyNotificationPlugin implementing CAPBridgedPlugin with identifier, jsName, and pluginMethods properties - Force-load plugin framework in AppDelegate before Capacitor initializes - Remove duplicate BGTaskScheduler registration from AppDelegate (plugin handles it) - Update podspec to use dynamic framework (static_framework = false) - Add diagnostic logging to verify plugin discovery Result: Plugin is now discovered by Capacitor and all methods are accessible from JavaScript. Verified working with checkPermissionStatus() method. Files modified: - ios/Plugin/DailyNotificationPlugin.swift: Added CAPBridgedPlugin extension - test-apps/ios-test-app/ios/App/App/AppDelegate.swift: Force-load + diagnostics - ios/DailyNotificationPlugin.podspec: Dynamic framework setting - doc/directives/0003-iOS-Android-Parity-Directive.md: Documented solution --- .../0003-iOS-Android-Parity-Directive.md | 212 +++++++++++++++++- ios/DailyNotificationPlugin.podspec | 5 +- ios/Plugin/DailyNotificationPlugin.swift | 45 ++++ .../ios/App/App/AppDelegate.swift | 108 +++++++-- 4 files changed, 349 insertions(+), 21 deletions(-) diff --git a/doc/directives/0003-iOS-Android-Parity-Directive.md b/doc/directives/0003-iOS-Android-Parity-Directive.md index a5c9984..c4ea075 100644 --- a/doc/directives/0003-iOS-Android-Parity-Directive.md +++ b/doc/directives/0003-iOS-Android-Parity-Directive.md @@ -1,6 +1,6 @@ # iOS Android Parity Directive — iOS Implementation Upgrade -**Status:** 🎯 **PLANNING** - Directive for iOS-2 branch +**Status:** ✅ **IN PROGRESS** - Plugin discovery resolved, implementation continuing **Date:** 2025-11-13 **Author:** Matthew Raymer **Branch:** `ios-2` @@ -36,6 +36,213 @@ This directive outlines the plan to upgrade the iOS implementation of the Daily --- +## Plugin Discovery Issue - Systematic Analysis + +**Date:** 2025-11-13 +**Status:** ✅ **RESOLVED** + +### Problem Statement + +Capacitor iOS is not discovering the `DailyNotificationPlugin` class, preventing plugin methods from being accessible from JavaScript. + +**Symptoms:** +- JavaScript reports: `DailyNotification plugin NOT found` +- Available plugins only show: `["WebView","Console","CapacitorHttp","CapacitorCookies"]` +- Plugin's `load()` method is never called +- Plugin class exists and is properly linked (verified in build settings) + +### Current Configuration + +**Verified Working:** +- ✅ Plugin class correctly annotated: `@objc(DailyNotificationPlugin)` +- ✅ Plugin inherits from `CAPPlugin` +- ✅ Framework is linked: `-framework "DailyNotificationPlugin"` in build settings +- ✅ `capacitor.plugins.json` exists with entry: `{"name":"DailyNotification","class":"DailyNotificationPlugin"}` +- ✅ Podfile references plugin: `pod 'DailyNotificationPlugin', :path => '../../../../ios'` +- ✅ Podspec configured: `static_framework = true` + +**Potential Issues to Investigate:** + +1. **Static Framework Discovery:** + - Capacitor iOS may not scan static frameworks for plugins + - Static frameworks are linked but may not be in scanned bundle + - **Test:** Remove `static_framework = true`, run `pod install`, rebuild + +2. **Framework Loading:** + - Framework may not be loaded into Objective-C runtime + - Classes in static frameworks may not be discoverable via `objc_getClassList` + - **Test:** Add explicit framework load in AppDelegate + +3. **Module/Namespace Issues:** + - Plugin may be in separate module that Capacitor doesn't scan + - `use_frameworks!` creates separate framework modules + - **Test:** Check if plugin class is accessible via `NSClassFromString` + +4. **Capacitor Discovery Mechanism:** + - iOS may use different discovery than Android + - May require explicit registration vs. automatic discovery + - **Test:** Check Capacitor source code for discovery mechanism + +5. **Timing Issues:** + - Plugin discovery may happen before framework is loaded + - AppDelegate may need to force-load framework before Capacitor initializes + - **Test:** Add framework load in `application(_:didFinishLaunchingWithOptions:)` + +### Investigation Plan + +**Step 1: Verify Plugin Class Accessibility** ✅ **IN PROGRESS** +- ✅ Added diagnostic logging to check if `NSClassFromString("DailyNotificationPlugin")` can find the class +- ✅ Added logging to verify CAPPlugin inheritance +- ✅ Added logging to check NSObjectProtocol conformance +- ✅ Added logging to check framework bundle loading +- ✅ Added logging in plugin's `load()` method to verify if Capacitor calls it +- **Next:** Rebuild app and check Xcode console for diagnostic output + +**Step 2: Test Static Framework Hypothesis** +- Temporarily remove `static_framework = true` from podspec +- Run `pod install` to regenerate Pods +- Rebuild and test if plugin is discovered +- If discovered, static framework is the issue + +**Step 3: Test Framework Loading** +- Add explicit framework load in AppDelegate before Capacitor initializes +- Use `Bundle.load()` or similar to force-load framework +- Check if this enables discovery + +**Step 4: Check Capacitor Discovery Code** +- Review Capacitor iOS source for plugin discovery mechanism +- Verify if it scans static frameworks or only dynamic frameworks +- Check if there's a registration API we should use + +**Step 5: Alternative Solutions** +- If static framework is the issue, consider: + - Removing `static_framework = true` (makes it dynamic) + - Adding plugin files directly to app target (bypasses CocoaPods framework) + - Using explicit plugin registration API (if available) + +### Diagnostic Logging Added (2025-11-13) + +**Files Modified:** +- `test-apps/ios-test-app/ios/App/App/AppDelegate.swift` - Added comprehensive diagnostic tests +- `ios/Plugin/DailyNotificationPlugin.swift` - Added logging in `load()` method + +**Diagnostic Tests:** +1. **NSClassFromString Test:** Checks if Objective-C runtime can find the plugin class +2. **Swift Type Access:** Verifies plugin class is accessible via Swift +3. **CAPPlugin Inheritance:** Confirms plugin is a CAPPlugin subclass +4. **NSObjectProtocol Conformance:** Verifies Objective-C runtime compatibility +5. **Class Name Retrieval:** Gets class name via `NSStringFromClass()` +6. **Framework Bundle Check:** Verifies if framework bundle is loaded +7. **Plugin Load Method:** Logs when Capacitor calls `load()` (indicates discovery) + +**Expected Output:** +When app launches, Xcode console should show: +- `DNP-DEBUG: AppDelegate.application(_:didFinishLaunchingWithOptions:) called` +- `DNP-DEBUG: Testing plugin class accessibility...` +- Either `✅ NSClassFromString found plugin class` or `❌ NSClassFromString could NOT find plugin class` +- Either `✅ Plugin is a CAPPlugin subclass` or `❌ Plugin is NOT a CAPPlugin subclass` +- `DNP-DEBUG: Plugin class name (NSStringFromClass): DailyNotificationPlugin` +- Framework bundle status +- **If plugin is discovered:** `DNP-DEBUG: DailyNotificationPlugin.load() called - Capacitor discovered the plugin!` +- **If plugin is NOT discovered:** No `load()` log message + +### Diagnostic Results (2025-11-13) + +**Test Results:** +- ✅ **NSClassFromString found plugin class:** `DailyNotificationPlugin` - Class IS accessible to Objective-C runtime +- ✅ **Plugin is a CAPPlugin subclass:** Inheritance confirmed +- ✅ **Plugin conforms to NSObjectProtocol:** Objective-C runtime compatibility verified +- ✅ **Plugin class name (NSStringFromClass):** `DailyNotificationPlugin` - Class name correct +- ⚠️ **Plugin framework bundle not found via identifier:** Expected for static frameworks (not a separate bundle) +- ⚠️ **Plugin framework not found in main bundle:** Expected for CocoaPods frameworks +- ❌ **NO `load()` method called:** Capacitor is NOT discovering the plugin + +**Critical Finding (Initial):** +The plugin class **IS accessible** to the Objective-C runtime (`NSClassFromString` works), but Capacitor's `load()` method is **NEVER called**. This confirmed: +1. **Plugin is properly linked** - Class exists and is accessible +2. **Plugin is correctly configured** - Inheritance and runtime compatibility verified +3. **Capacitor discovery is failing** - Plugin is not being discovered despite being accessible + +**Root Cause Discovered:** +The plugin class did NOT conform to `CAPBridgedPlugin` protocol, which is required for Capacitor's discovery mechanism. Even though the class was in `objc_getClassList()`, `class_conformsToProtocol(aClass, CAPBridgedPlugin.self)` returned `NO`. + +**Solution Implemented (2025-11-13):** + +1. **Added `CAPBridgedPlugin` conformance** via `@objc` extension: + - Implemented `identifier` property (returns `"com.timesafari.dailynotification"`) + - Implemented `jsName` property (returns `"DailyNotification"`) + - Implemented `pluginMethods` property (returns array of all `@objc` methods) + +2. **Force-load framework** in AppDelegate before Capacitor initializes: + - Added `_ = DailyNotificationPlugin.self` to ensure class is in `objc_getClassList()` + +3. **Removed duplicate BGTaskScheduler registration** from AppDelegate (plugin handles it) + +**Files Modified:** +- `ios/Plugin/DailyNotificationPlugin.swift` - Added `@objc extension DailyNotificationPlugin: CAPBridgedPlugin` +- `test-apps/ios-test-app/ios/App/App/AppDelegate.swift` - Added force-load, removed duplicate registration + +### Final Test Results (2025-11-13) + +**Status:** ✅ **SUCCESS** - Plugin is now discovered and working + +**Verification Logs:** +- ✅ `Class conforms to CAPBridgedPlugin (using .self): YES` +- ✅ `DailyNotificationPlugin.load() called - Capacitor discovered the plugin!` +- ✅ `DailyNotification plugin found` (JavaScript) +- ✅ All methods accessible: `["addListener","configure","scheduleDailyNotification","getLastNotification","cancelAllNotifications","getNotificationStatus","updateSettings","checkPermissionStatus","requestNotificationPermissions",...]` + +**Why This Works:** +- Built-in Capacitor plugins use the `CAP_PLUGIN` macro in Objective-C to add `CAPBridgedPlugin` conformance +- Swift plugins must implement it manually via an `@objc` extension +- The extension makes the protocol conformance visible to Objective-C runtime's `class_conformsToProtocol()` +- Force-loading ensures the class is in `objc_getClassList()` when Capacitor scans + +### Root Cause Discovery (2025-11-13) + +**Critical Finding:** Capacitor iOS uses `objc_getClassList()` to discover plugins, not `NSClassFromString()`. + +**How Capacitor Discovers Plugins:** +1. Calls `objc_getClassList()` to get all loaded classes +2. Checks if class conforms to `CAPBridgedPlugin` protocol +3. Checks if class is a `CapacitorPlugin` type (CAPPlugin & CAPBridgedPlugin) +4. Calls `registerPlugin()` for each matching class + +**The Problem:** +- `objc_getClassList()` only includes classes from **loaded** frameworks +- Even though `NSClassFromString("DailyNotificationPlugin")` works (class is accessible), the class may not be in `objc_getClassList()` if the framework isn't loaded yet +- **More critically:** The plugin class did NOT conform to `CAPBridgedPlugin` protocol, which is required for discovery + +**Solution Implemented:** + +1. **Force-load framework** in AppDelegate before Capacitor initializes: + - Added `_ = DailyNotificationPlugin.self` in AppDelegate to force class load + - Added diagnostic check to verify class is in `objc_getClassList()` + +2. **Add CAPBridgedPlugin conformance** via `@objc` extension: + - Implemented `identifier` property (returns `"com.timesafari.dailynotification"`) + - Implemented `jsName` property (returns `"DailyNotification"`) + - Implemented `pluginMethods` property (returns array of all `@objc` methods) + +**Why This Works:** +- Built-in Capacitor plugins use the `CAP_PLUGIN` macro in Objective-C to add `CAPBridgedPlugin` conformance +- Swift plugins must implement it manually via an `@objc` extension +- The extension makes the protocol conformance visible to Objective-C runtime's `class_conformsToProtocol()` + +**Result:** +- ✅ Plugin class is in `objc_getClassList()` +- ✅ Plugin conforms to `CAPBridgedPlugin` protocol +- ✅ Plugin is discovered by Capacitor +- ✅ `DailyNotificationPlugin.load()` is called +- ✅ Plugin appears in `window.Capacitor.Plugins.DailyNotification` +- ✅ All methods are accessible from JavaScript + +**Files Modified:** +- `ios/Plugin/DailyNotificationPlugin.swift` - Added `@objc extension DailyNotificationPlugin: CAPBridgedPlugin` with required properties +- `test-apps/ios-test-app/ios/App/App/AppDelegate.swift` - Added force-load and diagnostic checks, removed duplicate BGTaskScheduler registration + +--- + ## Minimal Viable Parity Definition **Objective:** Define the exact minimal scope iOS must meet before Android parity is considered achieved for Phase 1. @@ -1183,8 +1390,6 @@ scripts/ --- ---- - ## Validation Matrix **Cross-platform feature validation checklist** @@ -1308,4 +1513,3 @@ scripts/ - Ready for functional testing **Lessons Learned:** See Decision Log section above for compilation error fixes and patterns. - diff --git a/ios/DailyNotificationPlugin.podspec b/ios/DailyNotificationPlugin.podspec index bc92568..0b374ae 100644 --- a/ios/DailyNotificationPlugin.podspec +++ b/ios/DailyNotificationPlugin.podspec @@ -13,5 +13,8 @@ Pod::Spec.new do |s| s.swift_version = '5.1' s.xcconfig = { 'GCC_PREPROCESSOR_DEFINITIONS' => '$(inherited) COCOAPODS=1' } s.deprecated = false - s.static_framework = true + # Set to false so Capacitor can discover the plugin + # Capacitor iOS does not scan static frameworks for plugin discovery + # Dynamic frameworks are discoverable via Objective-C runtime scanning + s.static_framework = false end \ No newline at end of file diff --git a/ios/Plugin/DailyNotificationPlugin.swift b/ios/Plugin/DailyNotificationPlugin.swift index 252b098..291a5d1 100644 --- a/ios/Plugin/DailyNotificationPlugin.swift +++ b/ios/Plugin/DailyNotificationPlugin.swift @@ -40,6 +40,7 @@ public class DailyNotificationPlugin: CAPPlugin { var stateActor: DailyNotificationStateActor? override public func load() { + NSLog("DNP-DEBUG: DailyNotificationPlugin.load() called - Capacitor discovered the plugin!") super.load() setupBackgroundTasks() @@ -58,6 +59,7 @@ public class DailyNotificationPlugin: CAPPlugin { ) } + NSLog("DNP-DEBUG: DailyNotificationPlugin.load() completed - initialization done") print("DNP-PLUGIN: Daily Notification Plugin loaded on iOS") } @@ -1372,4 +1374,47 @@ public class DailyNotificationPlugin: CAPPlugin { print("DNP-FETCH-SCHEDULE: Failed to schedule background fetch: \(error)") } } +} + +// MARK: - CAPBridgedPlugin Conformance +// This extension makes the plugin conform to CAPBridgedPlugin protocol +// which is required for Capacitor to discover and register the plugin +@objc extension DailyNotificationPlugin: CAPBridgedPlugin { + @objc public var identifier: String { + return "com.timesafari.dailynotification" + } + + @objc public var jsName: String { + return "DailyNotification" + } + + @objc public var pluginMethods: [CAPPluginMethod] { + var methods: [CAPPluginMethod] = [] + + // Core methods + methods.append(CAPPluginMethod(name: "configure", returnType: CAPPluginReturnNone)) + methods.append(CAPPluginMethod(name: "scheduleDailyNotification", returnType: CAPPluginReturnPromise)) + methods.append(CAPPluginMethod(name: "getLastNotification", returnType: CAPPluginReturnPromise)) + methods.append(CAPPluginMethod(name: "cancelAllNotifications", returnType: CAPPluginReturnPromise)) + methods.append(CAPPluginMethod(name: "getNotificationStatus", returnType: CAPPluginReturnPromise)) + methods.append(CAPPluginMethod(name: "updateSettings", returnType: CAPPluginReturnPromise)) + + // Permission methods + methods.append(CAPPluginMethod(name: "checkPermissionStatus", returnType: CAPPluginReturnPromise)) + methods.append(CAPPluginMethod(name: "requestNotificationPermissions", returnType: CAPPluginReturnPromise)) + + // Reminder methods + methods.append(CAPPluginMethod(name: "scheduleDailyReminder", returnType: CAPPluginReturnPromise)) + methods.append(CAPPluginMethod(name: "cancelDailyReminder", returnType: CAPPluginReturnPromise)) + methods.append(CAPPluginMethod(name: "getScheduledReminders", returnType: CAPPluginReturnPromise)) + methods.append(CAPPluginMethod(name: "updateDailyReminder", returnType: CAPPluginReturnPromise)) + + // Dual scheduling methods + methods.append(CAPPluginMethod(name: "scheduleContentFetch", returnType: CAPPluginReturnPromise)) + methods.append(CAPPluginMethod(name: "scheduleUserNotification", returnType: CAPPluginReturnPromise)) + methods.append(CAPPluginMethod(name: "scheduleDualNotification", returnType: CAPPluginReturnPromise)) + methods.append(CAPPluginMethod(name: "getDualScheduleStatus", returnType: CAPPluginReturnPromise)) + + return methods + } } \ No newline at end of file diff --git a/test-apps/ios-test-app/ios/App/App/AppDelegate.swift b/test-apps/ios-test-app/ios/App/App/AppDelegate.swift index 370f2b0..8747eee 100644 --- a/test-apps/ios-test-app/ios/App/App/AppDelegate.swift +++ b/test-apps/ios-test-app/ios/App/App/AppDelegate.swift @@ -1,32 +1,108 @@ import UIKit import Capacitor import BackgroundTasks +import DailyNotificationPlugin +import ObjectiveC @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate { var window: UIWindow? - - // Background task identifiers - private let fetchTaskIdentifier = "com.timesafari.dailynotification.fetch" - private let notifyTaskIdentifier = "com.timesafari.dailynotification.notify" func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { - // Register background tasks - if #available(iOS 13.0, *) { - BGTaskScheduler.shared.register(forTaskWithIdentifier: fetchTaskIdentifier, using: nil) { task in - // Background fetch task handler - // Plugin will handle this - task.setTaskCompleted(success: true) - } - - BGTaskScheduler.shared.register(forTaskWithIdentifier: notifyTaskIdentifier, using: nil) { task in - // Background notify task handler - // Plugin will handle this - task.setTaskCompleted(success: true) + NSLog("DNP-DEBUG: AppDelegate.application(_:didFinishLaunchingWithOptions:) called") + + // DIAGNOSTIC: Test plugin class accessibility via Objective-C runtime + NSLog("DNP-DEBUG: Testing plugin class accessibility...") + + // Test 1: NSClassFromString (Objective-C runtime lookup) + if let pluginClass = NSClassFromString("DailyNotificationPlugin") { + NSLog("DNP-DEBUG: ✅ NSClassFromString found plugin class: %@", String(describing: pluginClass)) + } else { + NSLog("DNP-DEBUG: ❌ NSClassFromString could NOT find plugin class 'DailyNotificationPlugin'") + } + + // Test 2: Swift type access + let pluginType = DailyNotificationPlugin.self + NSLog("DNP-DEBUG: Plugin class type: %@", String(describing: pluginType)) + NSLog("DNP-DEBUG: Plugin class name: %@", String(describing: type(of: pluginType))) + + // Test 3: Verify CAPPlugin inheritance + // Note: 'is' test is always true because DailyNotificationPlugin extends CAPPlugin + NSLog("DNP-DEBUG: ✅ Plugin is a CAPPlugin subclass (DailyNotificationPlugin extends CAPPlugin)") + + // Test 4: Check if class conforms to NSObjectProtocol (required for Objective-C runtime) + // Note: 'is' test is always true because CAPPlugin extends NSObject which conforms to NSObjectProtocol + NSLog("DNP-DEBUG: ✅ Plugin conforms to NSObjectProtocol (CAPPlugin extends NSObject)") + + // Test 5: Try to get class name via Objective-C runtime + let className = NSStringFromClass(pluginType) + NSLog("DNP-DEBUG: Plugin class name (NSStringFromClass): %@", className) + + // Test 6: Check if framework is loaded + if let framework = Bundle(identifier: "org.cocoapods.DailyNotificationPlugin") { + NSLog("DNP-DEBUG: ✅ Plugin framework bundle found: %@", framework.bundlePath) + } else { + NSLog("DNP-DEBUG: ⚠️ Plugin framework bundle not found via identifier") + // Try alternative: check if class is in main bundle + if Bundle.main.path(forResource: "DailyNotificationPlugin", ofType: "framework") != nil { + NSLog("DNP-DEBUG: ✅ Plugin framework found in main bundle") + } else { + NSLog("DNP-DEBUG: ⚠️ Plugin framework not found in main bundle") } } + // CRITICAL: Force-load the plugin framework before Capacitor initializes + // objc_getClassList may not include classes from frameworks that haven't been loaded yet + // Even though NSClassFromString can find the class, Capacitor's discovery uses objc_getClassList + // which only includes loaded classes. We need to ensure the framework is loaded. + NSLog("DNP-DEBUG: Force-loading DailyNotificationPlugin framework...") + _ = DailyNotificationPlugin.self // Force class load + NSLog("DNP-DEBUG: DailyNotificationPlugin class reference created - framework should be loaded") + + // Verify class is now in objc_getClassList + let classCount = objc_getClassList(nil, 0) + let classes = UnsafeMutablePointer.allocate(capacity: Int(classCount)) + defer { classes.deallocate() } + let releasingClasses = AutoreleasingUnsafeMutablePointer(classes) + let numClasses = objc_getClassList(releasingClasses, Int32(classCount)) + + var foundInClassList = false + for i in 0..