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
This commit is contained in:
Server
2025-11-13 23:29:03 -08:00
parent 5844b92e18
commit ed25b1385a
4 changed files with 349 additions and 21 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -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<AnyClass?>.allocate(capacity: Int(classCount))
defer { classes.deallocate() }
let releasingClasses = AutoreleasingUnsafeMutablePointer<AnyClass>(classes)
let numClasses = objc_getClassList(releasingClasses, Int32(classCount))
var foundInClassList = false
for i in 0..<Int(numClasses) {
if let aClass = classes[i] {
let className = NSStringFromClass(aClass)
if className == "DailyNotificationPlugin" {
foundInClassList = true
NSLog("DNP-DEBUG: ✅ DailyNotificationPlugin found in objc_getClassList")
break
}
}
}
if !foundInClassList {
NSLog("DNP-DEBUG: ❌ DailyNotificationPlugin NOT found in objc_getClassList (this is the problem!)")
} else {
// Test if class conforms to CAPBridgedPlugin (required for Capacitor discovery)
if let aClass = NSClassFromString("DailyNotificationPlugin") {
// Try using CAPBridgedPlugin.self (the actual protocol, not string lookup)
// This is what Capacitor uses: class_conformsToProtocol(aClass, CAPBridgedPlugin.self)
let conformsToBridgedPlugin = class_conformsToProtocol(aClass, CAPBridgedPlugin.self)
NSLog("DNP-DEBUG: Class conforms to CAPBridgedPlugin (using .self): %@", conformsToBridgedPlugin ? "YES" : "NO")
// Test if class can be cast to CapacitorPlugin.Type
if let pluginType = aClass as? CAPPlugin.Type {
// Try casting to CapacitorPlugin (which is CAPPlugin & CAPBridgedPlugin)
if let capacitorPluginType = pluginType as? (CAPPlugin & CAPBridgedPlugin).Type {
NSLog("DNP-DEBUG: ✅ Can cast to (CAPPlugin & CAPBridgedPlugin).Type")
} else {
NSLog("DNP-DEBUG: ❌ Cannot cast to (CAPPlugin & CAPBridgedPlugin).Type")
}
}
}
}
// NOTE: Background task registration is handled by DailyNotificationPlugin.load()
// Do NOT register here to avoid duplicate registration crash
// Override point for customization after application launch.
return true
}