@capgo/capacitor-native-audio 8.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/CapgoCapacitorNativeAudio.podspec +16 -0
  2. package/LICENSE +373 -0
  3. package/Package.swift +31 -0
  4. package/README.md +1229 -0
  5. package/android/build.gradle +89 -0
  6. package/android/src/main/AndroidManifest.xml +3 -0
  7. package/android/src/main/java/ee/forgr/audio/AudioAsset.java +611 -0
  8. package/android/src/main/java/ee/forgr/audio/AudioCompletionListener.java +5 -0
  9. package/android/src/main/java/ee/forgr/audio/AudioDispatcher.java +208 -0
  10. package/android/src/main/java/ee/forgr/audio/Constant.java +36 -0
  11. package/android/src/main/java/ee/forgr/audio/HlsAvailabilityChecker.java +84 -0
  12. package/android/src/main/java/ee/forgr/audio/Logger.java +55 -0
  13. package/android/src/main/java/ee/forgr/audio/NativeAudio.java +2022 -0
  14. package/android/src/main/java/ee/forgr/audio/RemoteAudioAsset.java +886 -0
  15. package/android/src/main/java/ee/forgr/audio/StreamAudioAsset.java +708 -0
  16. package/android/src/main/res/values/colors.xml +3 -0
  17. package/android/src/main/res/values/strings.xml +3 -0
  18. package/android/src/main/res/values/styles.xml +3 -0
  19. package/dist/docs.json +1470 -0
  20. package/dist/esm/audio-asset.d.ts +4 -0
  21. package/dist/esm/audio-asset.js +6 -0
  22. package/dist/esm/audio-asset.js.map +1 -0
  23. package/dist/esm/definitions.d.ts +597 -0
  24. package/dist/esm/definitions.js +2 -0
  25. package/dist/esm/definitions.js.map +1 -0
  26. package/dist/esm/index.d.ts +4 -0
  27. package/dist/esm/index.js +7 -0
  28. package/dist/esm/index.js.map +1 -0
  29. package/dist/esm/web.d.ts +82 -0
  30. package/dist/esm/web.js +553 -0
  31. package/dist/esm/web.js.map +1 -0
  32. package/dist/plugin.cjs.js +571 -0
  33. package/dist/plugin.cjs.js.map +1 -0
  34. package/dist/plugin.js +574 -0
  35. package/dist/plugin.js.map +1 -0
  36. package/ios/Sources/NativeAudioPlugin/AudioAsset+Fade.swift +157 -0
  37. package/ios/Sources/NativeAudioPlugin/AudioAsset.swift +403 -0
  38. package/ios/Sources/NativeAudioPlugin/Constant.swift +52 -0
  39. package/ios/Sources/NativeAudioPlugin/Logger.swift +43 -0
  40. package/ios/Sources/NativeAudioPlugin/Plugin.swift +1786 -0
  41. package/ios/Sources/NativeAudioPlugin/RemoteAudioAsset+Fade.swift +152 -0
  42. package/ios/Sources/NativeAudioPlugin/RemoteAudioAsset.swift +405 -0
  43. package/ios/Tests/NativeAudioPluginTests/PluginTests.swift +648 -0
  44. package/ios/Tests/README.md +39 -0
  45. package/package.json +101 -0
  46. package/scripts/configure-dependencies.js +251 -0
@@ -0,0 +1,648 @@
1
+ import XCTest
2
+ import Capacitor
3
+ import AVFoundation
4
+ @testable import NativeAudioPlugin
5
+
6
+ // swiftlint:disable file_length
7
+ class PluginTests: XCTestCase {
8
+
9
+ var plugin = NativeAudio()
10
+ var tempFileURL = URL(fileURLWithPath: NSTemporaryDirectory().appending("testAudio.wav"))
11
+ var testAssetId = "testAssetId"
12
+ var testRemoteAssetId = "testRemoteAssetId"
13
+
14
+ override func setUp() {
15
+ super.setUp()
16
+ plugin = NativeAudio()
17
+ plugin.isRunningTests = true
18
+
19
+ // Create a temporary audio file for testing
20
+ let audioFilePath = NSTemporaryDirectory().appending("testAudio.wav")
21
+ tempFileURL = URL(fileURLWithPath: audioFilePath)
22
+
23
+ // Create a simple test audio file if needed
24
+ if !FileManager.default.fileExists(atPath: audioFilePath) {
25
+ createTestAudioFile(at: audioFilePath)
26
+ }
27
+ }
28
+
29
+ override func tearDown() {
30
+ // Clean up any audio assets
31
+ plugin.executeOnAudioQueue {
32
+ if let asset = self.plugin.audioList[self.testAssetId] as? AudioAsset {
33
+ asset.unload()
34
+ }
35
+ if let asset = self.plugin.audioList[self.testRemoteAssetId] as? RemoteAudioAsset {
36
+ asset.unload()
37
+ }
38
+ self.plugin.audioList.removeAll()
39
+ }
40
+
41
+ // Try to delete the temporary file
42
+ try? FileManager.default.removeItem(at: tempFileURL)
43
+
44
+ plugin = nil
45
+ super.tearDown()
46
+ }
47
+
48
+ // Helper method to create a simple test audio file
49
+ private func createTestAudioFile(at path: String) {
50
+ // This is a placeholder for a real implementation
51
+ // In a real scenario, you would create a small audio file for testing
52
+ // For now, we'll just create an empty file
53
+ FileManager.default.createFile(atPath: path, contents: Data(), attributes: nil)
54
+ }
55
+
56
+ private func makeCall(callbackId: String, options: [String: Any], onErrorMessage: String) -> CAPPluginCall {
57
+ guard let call = CAPPluginCall(callbackId: callbackId, options: options, success: { _, _ in }, error: { _ in }) else {
58
+ XCTFail("Failed to create CAPPluginCall: \(onErrorMessage)")
59
+ fatalError("Failed to create CAPPluginCall")
60
+ }
61
+ return call
62
+ }
63
+
64
+ }
65
+
66
+ extension PluginTests {
67
+
68
+ func testAudioAssetInitialization() {
69
+ let expectation = XCTestExpectation(description: "Initialize AudioAsset")
70
+
71
+ plugin.executeOnAudioQueue {
72
+ // Create an audio asset
73
+ let asset = AudioAsset(
74
+ owner: self.plugin,
75
+ withAssetId: self.testAssetId,
76
+ withPath: self.tempFileURL.path,
77
+ withChannels: 1,
78
+ withVolume: 0.5
79
+ )
80
+
81
+ // Add it to the plugin's audio list
82
+ self.plugin.audioList[self.testAssetId] = asset
83
+
84
+ // Verify initial values
85
+ XCTAssertEqual(asset.assetId, self.testAssetId)
86
+ XCTAssertEqual(asset.initialVolume, 0.5)
87
+
88
+ expectation.fulfill()
89
+ }
90
+
91
+ wait(for: [expectation], timeout: 5.0)
92
+ }
93
+
94
+ func testAudioAssetVolumeControl() {
95
+ let expectation = XCTestExpectation(description: "Test volume control")
96
+
97
+ plugin.executeOnAudioQueue {
98
+ // Create an audio asset
99
+ let asset = AudioAsset(
100
+ owner: self.plugin,
101
+ withAssetId: self.testAssetId,
102
+ withPath: self.tempFileURL.path,
103
+ withChannels: 1,
104
+ withVolume: 1.0
105
+ )
106
+
107
+ // Add it to the plugin's audio list
108
+ self.plugin.audioList[self.testAssetId] = asset
109
+
110
+ // Test setting volume
111
+ let testVolume: Float = 0.7
112
+ asset.setVolume(volume: NSNumber(value: testVolume), fadeDuration: 0)
113
+
114
+ // We can't directly check player.volume as it may take time to set
115
+ // So we'll just verify the method doesn't crash
116
+
117
+ expectation.fulfill()
118
+ }
119
+
120
+ wait(for: [expectation], timeout: 5.0)
121
+ }
122
+
123
+ func testRemoteAudioAssetInitialization() {
124
+ let expectation = XCTestExpectation(description: "Initialize RemoteAudioAsset")
125
+
126
+ // Use a publicly accessible test audio URL
127
+ let testURL = "https://file-examples.com/storage/fe5947fd2362a2f06a86851/2017/11/file_example_MP3_700KB.mp3"
128
+
129
+ plugin.executeOnAudioQueue {
130
+ // Create a remote audio asset
131
+ let asset = RemoteAudioAsset(
132
+ owner: self.plugin,
133
+ withAssetId: self.testRemoteAssetId,
134
+ withPath: testURL,
135
+ withChannels: 1,
136
+ withVolume: 0.6,
137
+ withHeaders: nil
138
+ )
139
+
140
+ // Add it to the plugin's audio list
141
+ self.plugin.audioList[self.testRemoteAssetId] = asset
142
+
143
+ // Verify initial values
144
+ XCTAssertEqual(asset.assetId, self.testRemoteAssetId)
145
+ XCTAssertEqual(asset.initialVolume, 0.6)
146
+ XCTAssertNotNil(asset.asset, "AVURLAsset should be created")
147
+
148
+ expectation.fulfill()
149
+ }
150
+
151
+ wait(for: [expectation], timeout: 5.0)
152
+ }
153
+
154
+ func testPluginPreloadMethod() {
155
+ // Create a plugin call to test the preload method
156
+ let call = makeCall(callbackId: "test", options: [
157
+ "assetId": testAssetId,
158
+ "assetPath": tempFileURL.path,
159
+ "volume": 0.8,
160
+ "audioChannelNum": 2
161
+ ], onErrorMessage: "Preload call creation")
162
+
163
+ // Call the plugin method
164
+ plugin.preload(call)
165
+
166
+ // Verify the asset was loaded by checking if it exists in the audioList
167
+ plugin.executeOnAudioQueue {
168
+ XCTAssertNotNil(self.plugin.audioList[self.testAssetId])
169
+ if let asset = self.plugin.audioList[self.testAssetId] as? AudioAsset {
170
+ XCTAssertEqual(asset.assetId, self.testAssetId)
171
+ XCTAssertEqual(asset.initialVolume, 0.8)
172
+ } else {
173
+ XCTFail("Asset should be of type AudioAsset")
174
+ }
175
+ }
176
+ }
177
+
178
+ func testFadeEffects() {
179
+ let expectation = XCTestExpectation(description: "Test fade effects")
180
+
181
+ plugin.executeOnAudioQueue {
182
+ // Create an audio asset
183
+ let asset = AudioAsset(
184
+ owner: self.plugin,
185
+ withAssetId: self.testAssetId,
186
+ withPath: self.tempFileURL.path,
187
+ withChannels: 1,
188
+ withVolume: 1.0
189
+ )
190
+
191
+ // Test fade functionality (just make sure it doesn't crash)
192
+ asset.playWithFade(time: 0)
193
+
194
+ // Wait a short time for fade to start
195
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
196
+ // Then test stop with fade
197
+ asset.stopWithFade()
198
+
199
+ // Wait for fade to complete
200
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
201
+ expectation.fulfill()
202
+ }
203
+ }
204
+ }
205
+
206
+ wait(for: [expectation], timeout: 5.0)
207
+ }
208
+
209
+ // Test the ClearCache functionality
210
+ func testClearCache() {
211
+ // This is mostly a method call test to ensure it doesn't crash
212
+ RemoteAudioAsset.clearCache()
213
+
214
+ // We can't easily verify the cache was cleared without complex setup,
215
+ // but we can ensure the method completes without errors
216
+ XCTAssertTrue(true)
217
+ }
218
+
219
+ // Test notification observer pattern in RemoteAudioAsset
220
+ func testNotificationObserverPattern() {
221
+ let expectation = XCTestExpectation(description: "Test notification observer")
222
+
223
+ // Use a publicly accessible test audio URL
224
+ let testURL = "https://file-examples.com/storage/fe5947fd2362a2f06a86851/2017/11/file_example_MP3_700KB.mp3"
225
+
226
+ plugin.executeOnAudioQueue {
227
+ // Create a remote audio asset
228
+ let asset = RemoteAudioAsset(
229
+ owner: self.plugin,
230
+ withAssetId: self.testRemoteAssetId,
231
+ withPath: testURL,
232
+ withChannels: 1,
233
+ withVolume: 0.6,
234
+ withHeaders: nil
235
+ )
236
+
237
+ // Add it to the plugin's audio list
238
+ self.plugin.audioList[self.testRemoteAssetId] = asset
239
+
240
+ // Verify initial values
241
+ XCTAssertEqual(asset.notificationObservers.count, 0, "Should start with zero notification observers")
242
+
243
+ // Test the resume method which sets up a notification observer
244
+ asset.resume()
245
+
246
+ // Check that a notification observer was added
247
+ XCTAssertGreaterThan(asset.notificationObservers.count, 0, "Should have added notification observers")
248
+
249
+ // Now test cleanup
250
+ asset.cleanupNotificationObservers()
251
+ XCTAssertEqual(asset.notificationObservers.count, 0, "Should have removed all notification observers")
252
+
253
+ expectation.fulfill()
254
+ }
255
+
256
+ wait(for: [expectation], timeout: 5.0)
257
+ }
258
+
259
+ // Test the fade timer functionality, which was a key part of our fixes
260
+ func testFadeTimerFunctionality() {
261
+ let expectation = XCTestExpectation(description: "Test fade timer")
262
+
263
+ plugin.executeOnAudioQueue {
264
+ // Create an audio asset
265
+ let asset = AudioAsset(
266
+ owner: self.plugin,
267
+ withAssetId: self.testAssetId,
268
+ withPath: self.tempFileURL.path,
269
+ withChannels: 1,
270
+ withVolume: 1.0
271
+ )
272
+
273
+ // Ensure the fade timer is nil initially
274
+ XCTAssertNil(asset.fadeTimer, "Fade timer should be nil initially")
275
+
276
+ // Access the private method using reflection
277
+ // (this is a test-only approach to access private methods)
278
+ let selector = NSSelectorFromString("startVolumeRamp:to:player:")
279
+ if asset.responds(to: selector) {
280
+ // Create a test mock for AVAudioPlayer
281
+ guard let player = asset.channels.first else {
282
+ XCTFail("No audio player available")
283
+ expectation.fulfill()
284
+ return
285
+ }
286
+
287
+ // Set initial volume
288
+ player.volume = 1.0
289
+
290
+ // Invoke using performSelector - Note: perform only supports up to 2 'with:' parameters
291
+ // This test is disabled as the selector requires 3 parameters
292
+ // asset.perform(selector, with: NSNumber(value: 1.0), with: NSNumber(value: 0.0))
293
+
294
+ // Check that the fade timer was created
295
+ XCTAssertNotNil(asset.fadeTimer, "Fade timer should be created")
296
+
297
+ // Wait for fade to complete
298
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
299
+ // Fade should be complete, timer should be nil again
300
+ XCTAssertNil(asset.fadeTimer, "Fade timer should be nil after completion")
301
+ XCTAssertEqual(player.volume, 0.0, "Volume should be 0 after fade out")
302
+
303
+ expectation.fulfill()
304
+ }
305
+ } else {
306
+ XCTFail("startVolumeRamp method not available")
307
+ expectation.fulfill()
308
+ }
309
+ }
310
+
311
+ wait(for: [expectation], timeout: 5.0)
312
+ }
313
+
314
+ // MARK: - PlayOnce Tests
315
+
316
+ func testPlayOnceWithAutoPlay() {
317
+ let expectation = XCTestExpectation(description: "PlayOnce with auto-play")
318
+ var returnedAssetId: String?
319
+
320
+ let call = makeCall(callbackId: "test", options: [
321
+ "assetPath": tempFileURL.path,
322
+ "volume": 1.0,
323
+ "isUrl": true,
324
+ "autoPlay": true
325
+ ], onErrorMessage: "playOnce auto-play call")
326
+ _ = returnedAssetId
327
+
328
+ plugin.playOnce(call)
329
+
330
+ plugin.executeOnAudioQueue {
331
+
332
+ // Wait for async operations
333
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
334
+ // Verify asset was created and is in playOnceAssets
335
+ self.plugin.executeOnAudioQueue {
336
+ let playOnceAssets = self.plugin.playOnceAssets
337
+ XCTAssertTrue(playOnceAssets.count > 0, "Should have created a playOnce asset")
338
+
339
+ // Verify the returned assetId matches an entry in playOnceAssets and audioList
340
+ if let assetId = returnedAssetId {
341
+ XCTAssertTrue(playOnceAssets.contains(assetId), "Returned assetId should be in playOnceAssets")
342
+ XCTAssertNotNil(self.plugin.audioList[assetId], "Returned assetId should have corresponding AudioAsset")
343
+ } else {
344
+ XCTFail("Should have returned an assetId")
345
+ }
346
+
347
+ expectation.fulfill()
348
+ }
349
+ }
350
+ }
351
+
352
+ wait(for: [expectation], timeout: 3.0)
353
+ }
354
+
355
+ func testPlayOnceWithoutAutoPlay() {
356
+ let expectation = XCTestExpectation(description: "PlayOnce without auto-play")
357
+
358
+ let call = makeCall(callbackId: "test", options: [
359
+ "assetPath": tempFileURL.path,
360
+ "volume": 0.8,
361
+ "isUrl": true,
362
+ "autoPlay": false
363
+ ], onErrorMessage: "playOnce no-autoplay call")
364
+
365
+ plugin.playOnce(call)
366
+
367
+ plugin.executeOnAudioQueue {
368
+
369
+ // Wait for async operations
370
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
371
+ self.plugin.executeOnAudioQueue {
372
+ // Asset should be created but not playing
373
+ let playOnceAssets = self.plugin.playOnceAssets
374
+ XCTAssertTrue(playOnceAssets.count > 0, "Should have created a playOnce asset")
375
+
376
+ if let assetId = playOnceAssets.first,
377
+ let asset = self.plugin.audioList[assetId] as? AudioAsset {
378
+ // Verify asset exists but is not automatically playing
379
+ XCTAssertFalse(asset.channels.isEmpty, "Asset should have channels")
380
+
381
+ // Verify player is not playing when autoPlay is false
382
+ if let player = asset.channels.first {
383
+ XCTAssertFalse(player.isPlaying, "Player should not be playing when autoPlay is false")
384
+ }
385
+ }
386
+
387
+ expectation.fulfill()
388
+ }
389
+ }
390
+ }
391
+
392
+ wait(for: [expectation], timeout: 3.0)
393
+ }
394
+
395
+ func testPlayOnceCleanupAfterCompletion() {
396
+ let expectation = XCTestExpectation(description: "PlayOnce cleanup after completion")
397
+
398
+ let call = makeCall(callbackId: "test", options: [
399
+ "assetPath": tempFileURL.path,
400
+ "volume": 1.0,
401
+ "isUrl": true,
402
+ "autoPlay": true
403
+ ], onErrorMessage: "playOnce cleanup call")
404
+
405
+ plugin.playOnce(call)
406
+
407
+ plugin.executeOnAudioQueue {
408
+
409
+ // Get the asset ID
410
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
411
+ self.plugin.executeOnAudioQueue {
412
+ guard let assetId = self.plugin.playOnceAssets.first else {
413
+ XCTFail("No playOnce asset was created")
414
+ expectation.fulfill()
415
+ return
416
+ }
417
+
418
+ // Verify asset exists
419
+ XCTAssertNotNil(self.plugin.audioList[assetId], "Asset should exist")
420
+
421
+ // Simulate completion
422
+ if let asset = self.plugin.audioList[assetId] as? AudioAsset {
423
+ asset.onComplete?()
424
+ }
425
+
426
+ // Wait for cleanup
427
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
428
+ self.plugin.executeOnAudioQueue {
429
+ // Verify cleanup occurred
430
+ XCTAssertNil(self.plugin.audioList[assetId], "Asset should be removed after cleanup")
431
+ XCTAssertFalse(self.plugin.playOnceAssets.contains(assetId), "AssetId should be removed from playOnceAssets")
432
+
433
+ expectation.fulfill()
434
+ }
435
+ }
436
+ }
437
+ }
438
+ }
439
+
440
+ wait(for: [expectation], timeout: 5.0)
441
+ }
442
+
443
+ func testPlayOnceWithDeleteAfterPlay() {
444
+ let expectation = XCTestExpectation(description: "PlayOnce with file deletion")
445
+
446
+ // Create a temporary file that can be deleted
447
+ let deletableFilePath = NSTemporaryDirectory().appending("deletableAudio.wav")
448
+ let deletableURL = URL(fileURLWithPath: deletableFilePath)
449
+
450
+ // Copy test file to deletable location
451
+ do {
452
+ // Remove existing file if present
453
+ if FileManager.default.fileExists(atPath: deletableFilePath) {
454
+ try FileManager.default.removeItem(at: deletableURL)
455
+ }
456
+ try FileManager.default.copyItem(at: tempFileURL, to: deletableURL)
457
+ } catch {
458
+ XCTFail("Failed to set up deletable file: \(error)")
459
+ expectation.fulfill()
460
+ return
461
+ }
462
+
463
+ let call = makeCall(callbackId: "test", options: [
464
+ "assetPath": deletableURL.absoluteString,
465
+ "volume": 1.0,
466
+ "isUrl": true,
467
+ "autoPlay": true,
468
+ "deleteAfterPlay": true
469
+ ], onErrorMessage: "playOnce delete-after-play call")
470
+
471
+ plugin.playOnce(call)
472
+
473
+ plugin.executeOnAudioQueue {
474
+
475
+ // Wait for asset creation
476
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
477
+ self.plugin.executeOnAudioQueue {
478
+ guard let assetId = self.plugin.playOnceAssets.first else {
479
+ XCTFail("No playOnce asset was created")
480
+ expectation.fulfill()
481
+ return
482
+ }
483
+
484
+ // Verify file exists before completion
485
+ XCTAssertTrue(FileManager.default.fileExists(atPath: deletableFilePath), "File should exist before cleanup")
486
+
487
+ // Simulate completion
488
+ if let asset = self.plugin.audioList[assetId] as? AudioAsset {
489
+ asset.onComplete?()
490
+ }
491
+
492
+ // Wait for cleanup and deletion
493
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
494
+ // File should be deleted after cleanup
495
+ let fileExists = FileManager.default.fileExists(atPath: deletableFilePath)
496
+ XCTAssertFalse(fileExists, "File should be deleted after playOnce completion")
497
+
498
+ expectation.fulfill()
499
+ }
500
+ }
501
+ }
502
+ }
503
+
504
+ wait(for: [expectation], timeout: 5.0)
505
+ }
506
+
507
+ }
508
+
509
+ extension PluginTests {
510
+
511
+ func testPlayOnceWithNotificationMetadata() {
512
+ let expectation = XCTestExpectation(description: "PlayOnce with notification metadata")
513
+
514
+ let call = makeCall(callbackId: "test", options: [
515
+ "assetPath": tempFileURL.path,
516
+ "volume": 1.0,
517
+ "isUrl": true,
518
+ "autoPlay": false,
519
+ "notificationMetadata": [
520
+ "title": "Test Song",
521
+ "artist": "Test Artist",
522
+ "album": "Test Album",
523
+ "artworkUrl": "https://example.com/artwork.jpg"
524
+ ]
525
+ ], onErrorMessage: "playOnce metadata call")
526
+
527
+ plugin.playOnce(call)
528
+
529
+ plugin.executeOnAudioQueue {
530
+
531
+ // Wait for async operations
532
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
533
+ self.plugin.executeOnAudioQueue {
534
+ guard let assetId = self.plugin.playOnceAssets.first else {
535
+ XCTFail("No playOnce asset was created")
536
+ expectation.fulfill()
537
+ return
538
+ }
539
+
540
+ // Verify notification metadata was stored
541
+ if let metadata = self.plugin.notificationMetadataMap[assetId] {
542
+ XCTAssertEqual(metadata["title"], "Test Song", "Title should be stored")
543
+ XCTAssertEqual(metadata["artist"], "Test Artist", "Artist should be stored")
544
+ XCTAssertEqual(metadata["album"], "Test Album", "Album should be stored")
545
+ XCTAssertEqual(metadata["artworkUrl"], "https://example.com/artwork.jpg", "Artwork URL should be stored")
546
+ } else {
547
+ XCTFail("Notification metadata should be stored")
548
+ }
549
+
550
+ expectation.fulfill()
551
+ }
552
+ }
553
+ }
554
+
555
+ wait(for: [expectation], timeout: 3.0)
556
+ }
557
+
558
+ func testPlayOnceErrorHandlingAndCleanup() {
559
+ let expectation = XCTestExpectation(description: "PlayOnce error handling and cleanup")
560
+
561
+ // Use an invalid file path to trigger error
562
+ let call = makeCall(callbackId: "test", options: [
563
+ "assetPath": "/invalid/path/to/nonexistent.wav",
564
+ "volume": 1.0,
565
+ "isUrl": true,
566
+ "autoPlay": true
567
+ ], onErrorMessage: "playOnce error handling call")
568
+
569
+ plugin.playOnce(call)
570
+
571
+ plugin.executeOnAudioQueue {
572
+ // Capture any assetId that might have been created
573
+ let initialAssetIds = self.plugin.playOnceAssets
574
+
575
+ // Wait for error handling
576
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
577
+ self.plugin.executeOnAudioQueue {
578
+ // Verify cleanup occurred even on failure
579
+ // Any asset created should be cleaned up from audioList
580
+ for assetId in initialAssetIds {
581
+ XCTAssertNil(self.plugin.audioList[assetId], "Failed asset should be cleaned up from audioList")
582
+ }
583
+
584
+ // And no dangling playOnce IDs should remain
585
+ XCTAssertTrue(self.plugin.playOnceAssets.isEmpty, "playOnce assets should be cleaned up on error")
586
+
587
+ expectation.fulfill()
588
+ }
589
+ }
590
+ }
591
+
592
+ wait(for: [expectation], timeout: 3.0)
593
+ }
594
+
595
+ func testPlayOnceReturnsUniqueAssetId() {
596
+ let expectation = XCTestExpectation(description: "PlayOnce returns unique asset ID")
597
+
598
+ var firstAssetId: String?
599
+ var secondAssetId: String?
600
+
601
+ let call1 = makeCall(callbackId: "test1", options: [
602
+ "assetPath": tempFileURL.path,
603
+ "volume": 1.0,
604
+ "isUrl": true,
605
+ "autoPlay": false
606
+ ], onErrorMessage: "playOnce unique call1")
607
+ _ = firstAssetId
608
+
609
+ plugin.playOnce(call1)
610
+
611
+ // Create second playOnce
612
+ let call2 = makeCall(callbackId: "test2", options: [
613
+ "assetPath": tempFileURL.path,
614
+ "volume": 1.0,
615
+ "isUrl": true,
616
+ "autoPlay": false
617
+ ], onErrorMessage: "playOnce unique call2")
618
+ _ = secondAssetId
619
+
620
+ plugin.playOnce(call2)
621
+
622
+ // Wait for both to complete
623
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
624
+ self.plugin.executeOnAudioQueue {
625
+ // Verify we got two distinct asset IDs from the public API
626
+ XCTAssertNotNil(firstAssetId, "First asset ID should exist")
627
+ XCTAssertNotNil(secondAssetId, "Second asset ID should exist")
628
+ XCTAssertNotEqual(firstAssetId, secondAssetId, "Asset IDs should be unique")
629
+
630
+ // Verify both have "playOnce_" prefix
631
+ XCTAssertTrue(firstAssetId?.hasPrefix("playOnce_") ?? false, "First asset ID should have playOnce prefix")
632
+ XCTAssertTrue(secondAssetId?.hasPrefix("playOnce_") ?? false, "Second asset ID should have playOnce prefix")
633
+
634
+ // Cross-check that both are present in playOnceAssets as internal consistency check
635
+ if let id1 = firstAssetId, let id2 = secondAssetId {
636
+ XCTAssertTrue(self.plugin.playOnceAssets.contains(id1), "First assetId should be tracked internally")
637
+ XCTAssertTrue(self.plugin.playOnceAssets.contains(id2), "Second assetId should be tracked internally")
638
+ XCTAssertEqual(self.plugin.playOnceAssets.count, 2, "Should have two playOnce assets tracked")
639
+ }
640
+
641
+ expectation.fulfill()
642
+ }
643
+ }
644
+
645
+ wait(for: [expectation], timeout: 5.0)
646
+ }
647
+ }
648
+ // swiftlint:enable file_length
@@ -0,0 +1,39 @@
1
+ # Native Audio Plugin Tests
2
+
3
+ This directory contains tests for the Capacitor Native Audio Plugin.
4
+
5
+ ## Running Tests
6
+
7
+ The easiest way to run these tests is through Xcode:
8
+
9
+ 1. Open the `Plugin.xcworkspace` file in Xcode
10
+ 2. Select the "Plugin" scheme
11
+ 3. Go to Product > Test (or press ⌘+U)
12
+
13
+ ## Test Coverage
14
+
15
+ The tests cover the following functionality:
16
+
17
+ - Basic initialization of `AudioAsset` and `RemoteAudioAsset` classes
18
+ - Volume control
19
+ - Fade effects (fade in/out)
20
+ - Notification observer pattern
21
+ - Cache clearing
22
+ - Plugin preloading
23
+
24
+ ## Notes for Test Development
25
+
26
+ - The tests use a temporary audio file created at runtime
27
+ - For remote audio tests, a publicly accessible MP3 file is used
28
+ - Some tests use reflection to access private methods (for testing purposes only)
29
+ - All tests run on the plugin's audio queue to maintain thread safety
30
+
31
+ ## Troubleshooting
32
+
33
+ If tests fail:
34
+
35
+ 1. Make sure the iOS simulator is available
36
+ 2. Check that the audio session is properly configured
37
+ 3. For remote tests, ensure network connectivity is available
38
+
39
+ To view detailed test logs, expand the test navigator in Xcode and click on the failing test.