feat(ios): implement testAlarm method and fix plugin discovery

Add testAlarm() method to iOS plugin for quick notification testing.
Fix plugin method discovery by registering testAlarm in CAPBridgedPlugin
pluginMethods array. Add force-load code in AppDelegate to ensure plugin
is discovered by Capacitor's objc_getClassList scan.

Changes:
- Add testAlarm() implementation in DailyNotificationPlugin.swift
- Register testAlarm in pluginMethods array (required for Capacitor discovery)
- Add force-load code in test app AppDelegate (matches working ios-test-app)
- Add UNUserNotificationCenterDelegate to show notifications in foreground
- Add test notification button to ScheduleView with immediate feedback
- Add debug logging for method discovery and plugin loading

Fixes issue where testAlarm was implemented but returned "UNIMPLEMENTED"
because it wasn't registered in the pluginMethods array. Also ensures
plugin class is loaded before Capacitor's discovery phase.
This commit is contained in:
Jose Olarte III
2025-12-31 17:25:52 +08:00
parent c8919480d9
commit edc4082f72
3 changed files with 355 additions and 5 deletions

View File

@@ -11,6 +11,7 @@ import Capacitor
import UserNotifications
import BackgroundTasks
import CoreData
import ObjectiveC
/**
* iOS implementation of Daily Notification Plugin
@@ -82,6 +83,34 @@ public class DailyNotificationPlugin: CAPPlugin {
NSLog("DNP-DEBUG: DailyNotificationPlugin.load() completed - initialization done")
print("DNP-PLUGIN: Daily Notification Plugin loaded on iOS")
// Debug: Log all available @objc methods for Capacitor discovery
let methods = getObjCMethods()
NSLog("DNP-DEBUG: Available @objc methods: \(methods.joined(separator: ", "))")
print("DNP-DEBUG: Available @objc methods: \(methods.joined(separator: ", "))")
}
/**
* Debug helper: Get all @objc methods for this class
*/
private func getObjCMethods() -> [String] {
var methods: [String] = []
var methodCount: UInt32 = 0
let methodList = class_copyMethodList(type(of: self), &methodCount)
for i in 0..<Int(methodCount) {
if let method = methodList?[i] {
let selector = method_getName(method)
let methodName = NSStringFromSelector(selector)
// Filter for methods that look like plugin methods (take CAPPluginCall)
if methodName.contains(":") && !methodName.hasPrefix("_") {
methods.append(methodName)
}
}
}
free(methodList)
return methods.sorted()
}
// MARK: - Configuration Methods
@@ -1120,6 +1149,129 @@ public class DailyNotificationPlugin: CAPPlugin {
}
}
/**
* Test method: Schedule an alarm to fire in a few seconds
* Useful for verifying alarm delivery works correctly
*
* @param call Plugin call with optional secondsFromNow (default: 5)
* @returns Object with scheduled (boolean), secondsFromNow (number), and triggerAtMillis (number)
*/
@objc func testAlarm(_ call: CAPPluginCall) {
NSLog("DNP-DEBUG: testAlarm() method CALLED - method is being invoked!")
print("DNP-DEBUG: testAlarm() method CALLED - method is being invoked!")
print("DNP-DEBUG: testAlarm call data: \(call.jsObjectRepresentation)")
guard let scheduler = scheduler else {
NSLog("DNP-DEBUG: testAlarm() - scheduler is nil, rejecting")
print("DNP-DEBUG: testAlarm() - scheduler is nil, rejecting")
let error = DailyNotificationErrorCodes.createErrorResponse(
code: DailyNotificationErrorCodes.PLUGIN_NOT_INITIALIZED,
message: "Plugin not initialized"
)
let errorMessage = error["message"] as? String ?? "Unknown error"
let errorCode = error["error"] as? String ?? "unknown_error"
call.reject(errorMessage, errorCode)
return
}
// Get secondsFromNow parameter (default: 5)
let secondsFromNow = call.getInt("secondsFromNow") ?? 5
// Ensure minimum of 1 second (iOS requirement)
let validSeconds = max(1, secondsFromNow)
Task {
do {
// Check permissions first
let permissionStatus = await notificationCenter.notificationSettings()
if permissionStatus.authorizationStatus != .authorized && permissionStatus.authorizationStatus != .provisional {
let error = DailyNotificationErrorCodes.createErrorResponse(
code: DailyNotificationErrorCodes.NOTIFICATIONS_DENIED,
message: "Notification permissions not granted"
)
let errorMessage = error["message"] as? String ?? "Unknown error"
let errorCode = error["error"] as? String ?? "unknown_error"
call.reject(errorMessage, errorCode)
return
}
// Create test notification content
let notificationContent = UNMutableNotificationContent()
notificationContent.title = "Test Notification"
notificationContent.body = "This is a test notification scheduled \(validSeconds) seconds from now"
notificationContent.sound = .default
notificationContent.categoryIdentifier = "DAILY_NOTIFICATION"
notificationContent.userInfo = [
"notification_id": "test_\(Date().timeIntervalSince1970)",
"is_test": true
]
// Create time interval trigger (fires in X seconds)
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: TimeInterval(validSeconds), repeats: false)
// Create notification request with unique ID
let notificationId = "test_alarm_\(Date().timeIntervalSince1970)"
let request = UNNotificationRequest(
identifier: notificationId,
content: notificationContent,
trigger: trigger
)
// Schedule notification
try await notificationCenter.add(request)
// Calculate trigger time in milliseconds
let triggerAtMillis = Int64((Date().timeIntervalSince1970 + Double(validSeconds)) * 1000)
let result: [String: Any] = [
"scheduled": true,
"secondsFromNow": validSeconds,
"triggerAtMillis": triggerAtMillis
]
print("DNP-PLUGIN: Test alarm scheduled for \(validSeconds) seconds from now (triggerAtMillis=\(triggerAtMillis))")
NSLog("DNP-DEBUG: testAlarm() - Successfully scheduled, resolving with result: \(result)")
DispatchQueue.main.async {
NSLog("DNP-DEBUG: testAlarm() - Resolving call with result")
call.resolve(result)
}
} catch {
NSLog("DNP-DEBUG: testAlarm() - Error caught: \(error)")
print("DNP-PLUGIN: Error scheduling test alarm: \(error)")
let errorResponse = DailyNotificationErrorCodes.createErrorResponse(
code: DailyNotificationErrorCodes.SCHEDULING_FAILED,
message: "Failed to schedule test alarm: \(error.localizedDescription)"
)
let errorMessage = errorResponse["message"] as? String ?? "Unknown error"
let errorCode = errorResponse["error"] as? String ?? "unknown_error"
NSLog("DNP-DEBUG: testAlarm() - Rejecting with error: \(errorMessage) (\(errorCode))")
DispatchQueue.main.async {
call.reject(errorMessage, errorCode)
}
}
}
}
/**
* Debug method: List all available plugin methods
* Useful for verifying Capacitor method discovery
*
* @param call Plugin call
*/
@objc func listAvailableMethods(_ call: CAPPluginCall) {
let methods = getObjCMethods()
let result: [String: Any] = [
"methods": methods,
"count": methods.count,
"testAlarmFound": methods.contains("testAlarm:")
]
NSLog("DNP-DEBUG: listAvailableMethods() - Found \(methods.count) methods")
NSLog("DNP-DEBUG: testAlarm: found: \(methods.contains("testAlarm:"))")
call.resolve(result)
}
/**
* Get the last notification that was delivered
*
@@ -1943,6 +2095,7 @@ public class DailyNotificationPlugin: CAPPlugin {
methods.append(CAPPluginMethod(name: "configure", returnType: CAPPluginReturnPromise))
methods.append(CAPPluginMethod(name: "configureNativeFetcher", returnType: CAPPluginReturnPromise))
methods.append(CAPPluginMethod(name: "scheduleDailyNotification", returnType: CAPPluginReturnPromise))
methods.append(CAPPluginMethod(name: "testAlarm", returnType: CAPPluginReturnPromise))
methods.append(CAPPluginMethod(name: "getLastNotification", returnType: CAPPluginReturnPromise))
methods.append(CAPPluginMethod(name: "cancelAllNotifications", returnType: CAPPluginReturnPromise))
methods.append(CAPPluginMethod(name: "getNotificationStatus", returnType: CAPPluginReturnPromise))

View File

@@ -1,12 +1,52 @@
import UIKit
import Capacitor
import BackgroundTasks
import DailyNotificationPlugin
import ObjectiveC
import UserNotifications
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate {
var window: UIWindow?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
NSLog("DNP-DEBUG: AppDelegate.application(_:didFinishLaunchingWithOptions:) called")
// 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!)")
}
// Set notification center delegate to show notifications in foreground
UNUserNotificationCenter.current().delegate = self
NSLog("DNP-DEBUG: UNUserNotificationCenter delegate set to AppDelegate")
// Override point for customization after application launch.
return true
}
@@ -27,6 +67,44 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
func applicationDidBecomeActive(_ application: UIApplication) {
// Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
// Re-set delegate when app becomes active (in case Capacitor resets it)
UNUserNotificationCenter.current().delegate = self
NSLog("DNP-DEBUG: UNUserNotificationCenter delegate re-set in applicationDidBecomeActive")
}
// MARK: - UNUserNotificationCenterDelegate
/**
* Show notifications even when app is in foreground
* This is required for test app to see notifications during testing
*/
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
NSLog("DNP-DEBUG: ✅ userNotificationCenter willPresent called!")
NSLog("DNP-DEBUG: Notification received in foreground: %@", notification.request.identifier)
NSLog("DNP-DEBUG: Notification title: %@", notification.request.content.title)
NSLog("DNP-DEBUG: Notification body: %@", notification.request.content.body)
// Show notification with banner, sound, and badge
// Use .banner for iOS 14+, fallback to .alert for iOS 13
if #available(iOS 14.0, *) {
completionHandler([.banner, .sound, .badge])
} else {
completionHandler([.alert, .sound, .badge])
}
NSLog("DNP-DEBUG: ✅ Completion handler called with presentation options")
}
/**
* Handle notification tap/interaction
*/
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
NSLog("DNP-DEBUG: Notification tapped: %@", response.notification.request.identifier)
NSLog("DNP-DEBUG: Action identifier: %@", response.actionIdentifier)
// Handle notification tap if needed
completionHandler()
}
func applicationWillTerminate(_ application: UIApplication) {

View File

@@ -39,6 +39,13 @@
<button class="action-button primary" @click="scheduleNotification" :disabled="isScheduling">
{{ isScheduling ? 'Scheduling...' : 'Schedule Notification' }}
</button>
<button class="action-button secondary" @click="testNotification" :disabled="isTesting">
{{ isTesting ? 'Testing...' : '🧪 Test Notification (5 seconds)' }}
</button>
</div>
<div v-if="scheduleFeedback" class="feedback-message" :class="scheduleFeedback.type">
{{ scheduleFeedback.message }}
</div>
</div>
</div>
@@ -54,6 +61,8 @@ class ScheduleView extends Vue {
notificationTitle = 'Daily Update'
notificationMessage = 'Your daily notification is ready!'
isScheduling = false
isTesting = false
scheduleFeedback: { type: 'success' | 'error' | 'info', message: string } | null = null
goBack() {
router.push('/')
@@ -61,6 +70,8 @@ class ScheduleView extends Vue {
async scheduleNotification() {
this.isScheduling = true
this.scheduleFeedback = null
try {
console.log('🔄 Starting notification scheduling...')
@@ -70,6 +81,17 @@ class ScheduleView extends Vue {
console.log('✅ Plugin loaded:', plugin)
// Calculate when the notification will fire
const [hours, minutes] = this.scheduleTime.split(':').map(Number)
const now = new Date()
const scheduledTime = new Date()
scheduledTime.setHours(hours, minutes, 0, 0)
// If the time has passed today, schedule for tomorrow
if (scheduledTime <= now) {
scheduledTime.setDate(scheduledTime.getDate() + 1)
}
const options = {
time: this.scheduleTime,
title: this.notificationTitle,
@@ -84,8 +106,22 @@ class ScheduleView extends Vue {
console.log('✅ Notification scheduled successfully!')
// Show success feedback to user
alert('Notification scheduled successfully!')
// Show success feedback with timing info
const timeUntil = scheduledTime.getTime() - now.getTime()
const hoursUntil = Math.floor(timeUntil / (1000 * 60 * 60))
const minutesUntil = Math.floor((timeUntil % (1000 * 60 * 60)) / (1000 * 60))
let timeMessage = ''
if (hoursUntil > 0) {
timeMessage = `Notification will appear in ${hoursUntil} hour${hoursUntil > 1 ? 's' : ''} and ${minutesUntil} minute${minutesUntil !== 1 ? 's' : ''} (at ${scheduledTime.toLocaleTimeString()})`
} else {
timeMessage = `Notification will appear in ${minutesUntil} minute${minutesUntil !== 1 ? 's' : ''} (at ${scheduledTime.toLocaleTimeString()})`
}
this.scheduleFeedback = {
type: 'success',
message: `✅ Notification scheduled successfully! ${timeMessage}. Use "Test Notification" to see one immediately.`
}
} catch (error) {
console.error('❌ Failed to schedule notification:', error)
@@ -95,12 +131,59 @@ class ScheduleView extends Vue {
stack: error.stack
})
// Show error feedback to user
alert(`Failed to schedule notification: ${error.message}`)
this.scheduleFeedback = {
type: 'error',
message: `❌ Failed to schedule notification: ${error.message}`
}
} finally {
this.isScheduling = false
}
}
async testNotification() {
this.isTesting = true
this.scheduleFeedback = null
try {
console.log('🧪 Testing notification (will fire in 5 seconds)...')
// Import and use the real plugin
const { DailyNotification } = await import('@timesafari/daily-notification-plugin')
const plugin = DailyNotification
console.log('✅ Plugin loaded:', plugin)
// Use testAlarm to schedule a notification that fires in 5 seconds
const result = await plugin.testAlarm({ secondsFromNow: 5 })
console.log('✅ Test notification scheduled:', result)
this.scheduleFeedback = {
type: 'success',
message: '✅ Test notification scheduled! It will appear in 5 seconds. Look for it at the top of your screen.'
}
// Clear feedback after 10 seconds
setTimeout(() => {
this.scheduleFeedback = null
}, 10000)
} catch (error) {
console.error('❌ Failed to schedule test notification:', error)
console.error('❌ Error details:', {
name: error.name,
message: error.message,
stack: error.stack
})
this.scheduleFeedback = {
type: 'error',
message: `❌ Failed to schedule test notification: ${error.message}`
}
} finally {
this.isTesting = false
}
}
}
export default toNative(ScheduleView)
</script>
@@ -229,6 +312,42 @@ export default toNative(ScheduleView)
cursor: not-allowed;
}
.action-button.secondary {
background: rgba(255, 255, 255, 0.2);
color: white;
margin-top: 12px;
}
.action-button.secondary:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.3);
}
.feedback-message {
margin-top: 16px;
padding: 12px;
border-radius: 8px;
font-size: 14px;
line-height: 1.5;
}
.feedback-message.success {
background: rgba(76, 175, 80, 0.2);
border: 1px solid rgba(76, 175, 80, 0.4);
color: #81c784;
}
.feedback-message.error {
background: rgba(244, 67, 54, 0.2);
border: 1px solid rgba(244, 67, 54, 0.4);
color: #e57373;
}
.feedback-message.info {
background: rgba(33, 150, 243, 0.2);
border: 1px solid rgba(33, 150, 243, 0.4);
color: #64b5f6;
}
/* Mobile responsiveness */
@media (max-width: 768px) {
.schedule-view {