Skip to content

Commit 1cd544b

Browse files
committed
Provide play sound for iOS find phone apps and others
1 parent c8f11a8 commit 1cd544b

5 files changed

Lines changed: 279 additions & 0 deletions

File tree

iosApp/iosApp/Info.plist

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@
164164
<string>bluetooth-central</string>
165165
<string>bluetooth-peripheral</string>
166166
<string>remote-notification</string>
167+
<string>audio</string>
167168
</array>
168169
<key>UILaunchScreen</key>
169170
<dict>

libpebble3/src/androidMain/assets/startup.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,24 @@ navigator.geolocation.clearWatch = (id) => {
383383
deleteTimelinePin: (id) => {
384384
_Pebble.deleteTimelinePin(id);
385385
},
386+
playSound: (type, text) => {
387+
_Pebble.playSound(type || "", text || "");
388+
},
389+
stopSound: () => {
390+
if (_Pebble.stopSound) {
391+
_Pebble.stopSound();
392+
}
393+
},
394+
playDefaultRingtone: () => {
395+
if (_Pebble.playDefaultRingtone) {
396+
_Pebble.playDefaultRingtone();
397+
}
398+
},
399+
playSoundById: (id) => {
400+
if (_Pebble.playSoundById) {
401+
_Pebble.playSoundById(id || "");
402+
}
403+
},
386404
}
387405
global.Pebble.addEventListener = PebbleAPI.addEventListener;
388406
global.Pebble.removeEventListener = PebbleAPI.removeEventListener;
@@ -395,6 +413,10 @@ navigator.geolocation.clearWatch = (id) => {
395413
global.Pebble.appGlanceReload = PebbleAPI.appGlanceReload;
396414
global.Pebble.insertTimelinePin = PebbleAPI.insertTimelinePin;
397415
global.Pebble.deleteTimelinePin = PebbleAPI.deleteTimelinePin;
416+
global.Pebble.playSound = PebbleAPI.playSound;
417+
global.Pebble.stopSound = PebbleAPI.stopSound;
418+
global.Pebble.playDefaultRingtone = PebbleAPI.playDefaultRingtone;
419+
global.Pebble.playSoundById = PebbleAPI.playSoundById;
398420

399421
// Enable intercepting XHR calls (on Android - this doesn't work on iOS so we don't add
400422
// shouldIntercept to the PKJS interface there).

libpebble3/src/androidMain/kotlin/io/rebble/libpebblecommon/js/WebViewPrivatePKJSInterface.kt

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,4 +122,27 @@ class WebViewPrivatePKJSInterface(
122122
override fun deleteTimelinePin(id: String) {
123123
super.deleteTimelinePin(id)
124124
}
125+
126+
@JavascriptInterface
127+
override fun playSound(type: String, text: String) {
128+
super.playSound(type, text)
129+
// TBD: Android sound implementation
130+
}
131+
132+
@JavascriptInterface
133+
override fun playSoundById(id: String) {
134+
super.playSoundById(id)
135+
// TBD: Android sound implementation (e.g. parse id as system sound)
136+
}
137+
138+
@JavascriptInterface
139+
override fun playDefaultRingtone() {
140+
super.playDefaultRingtone()
141+
// TBD: Android sound implementation (e.g. MediaPlayer with bundled res)
142+
}
143+
144+
@JavascriptInterface
145+
override fun stopSound() {
146+
super.stopSound()
147+
}
125148
}

libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/js/PrivatePKJSInterface.kt

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,4 +227,20 @@ abstract class PrivatePKJSInterface(
227227
val uuid = Uuid.parse(jsRunner.appInfo.uuid)
228228
runBlocking { remoteTimelineEmulator.deletePin(appUuid = uuid, pinIdentifier = id) }
229229
}
230+
231+
open fun playSound(type: String, text: String) {
232+
logger.v { "playSound($type, $text)" }
233+
}
234+
235+
open fun playSoundById(id: String) {
236+
logger.v { "playSoundById($id)" }
237+
}
238+
239+
open fun playDefaultRingtone() {
240+
logger.v { "playDefaultRingtone()" }
241+
}
242+
243+
open fun stopSound() {
244+
logger.v { "stopSound()" }
245+
}
230246
}

libpebble3/src/iosMain/kotlin/io/rebble/libpebblecommon/js/JSCPrivatePKJSInterface.kt

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,20 @@ import kotlinx.coroutines.CoroutineScope
66
import kotlinx.coroutines.channels.Channel
77
import kotlinx.coroutines.flow.MutableSharedFlow
88
import kotlinx.io.files.Path
9+
import platform.AVFAudio.AVAudioSession
10+
import platform.AVFAudio.AVAudioSessionCategoryOptionDuckOthers
11+
import platform.AVFAudio.AVAudioSessionCategoryPlayback
12+
import platform.AVFAudio.AVSpeechSynthesizer
13+
import platform.AVFAudio.AVSpeechSynthesizerDelegateProtocol
14+
import platform.AVFAudio.AVAudioPlayer
15+
import platform.AVFAudio.AVSpeechUtterance
16+
import platform.AVFAudio.setActive
17+
import platform.AudioToolbox.AudioServicesPlayAlertSound
18+
import platform.AudioToolbox.AudioServicesPlaySystemSound
19+
import platform.AudioToolbox.AudioServicesPlaySystemSoundWithCompletion
20+
import platform.Foundation.NSBundle
21+
import platform.Foundation.NSURL
22+
import platform.darwin.NSObject
923

1024
class JSCPrivatePKJSInterface(
1125
private val jsPath: Path,
@@ -20,6 +34,57 @@ class JSCPrivatePKJSInterface(
2034
notificationConfigFlow: NotificationConfigFlow,
2135
): PrivatePKJSInterface(jsRunner, device, scope, outgoingAppMessages, logMessages, jsTokenUtil, remoteTimelineEmulator, httpInterceptorManager, notificationConfigFlow), RegisterableJsInterface {
2236
private val logger = Logger.withTag("JSCPrivatePKJSInterface")
37+
38+
private var isLooping = false
39+
private var loopType = ""
40+
private var loopText = ""
41+
private var currentSystemSoundId: UInt = 0u
42+
private var soundGeneration = 0L
43+
private var avPlayer: AVAudioPlayer? = null
44+
private var voiceLoopCount = 0
45+
private val maxLoops = 3
46+
47+
private val speechDelegate = object : NSObject(), AVSpeechSynthesizerDelegateProtocol {
48+
override fun speechSynthesizer(synthesizer: AVSpeechSynthesizer, didFinishSpeechUtterance: AVSpeechUtterance) {
49+
if (isLooping && loopType == "voice_loop") {
50+
voiceLoopCount++
51+
if (voiceLoopCount < maxLoops) {
52+
val utterance = AVSpeechUtterance(string = loopText)
53+
synthesizer.speakUtterance(utterance)
54+
} else {
55+
isLooping = false
56+
loopType = ""
57+
}
58+
}
59+
}
60+
}
61+
62+
private val synthesizer = AVSpeechSynthesizer().apply {
63+
delegate = speechDelegate
64+
}
65+
66+
init {
67+
try {
68+
setupAudioSession()
69+
// Pre-warm the speech synthesizer with a silent utterance to eliminate first-time delay
70+
val silentUtterance = AVSpeechUtterance(string = " ")
71+
silentUtterance.setVolume(0.0f)
72+
synthesizer.speakUtterance(silentUtterance)
73+
prewarmRingtone()
74+
} catch (e: Exception) {
75+
logger.w(e) { "Failed to prime audio session" }
76+
}
77+
}
78+
79+
private fun prewarmRingtone() {
80+
val path = NSBundle.mainBundle.pathForResource("opening", "mp3") ?: return
81+
val url = NSURL.fileURLWithPath(path)
82+
val player = AVAudioPlayer(contentsOfURL = url, error = null) ?: return
83+
player.numberOfLoops = (maxLoops - 1).toLong()
84+
player.volume = 1.0f
85+
player.prepareToPlay()
86+
avPlayer = player
87+
}
2388

2489
override val interf = mapOf(
2590
"sendAppMessageString" to this::sendAppMessageString,
@@ -46,6 +111,10 @@ class JSCPrivatePKJSInterface(
46111
"getActivePebbleWatchInfo" to this::getActivePebbleWatchInfo,
47112
"insertTimelinePin" to this::insertTimelinePin,
48113
"deleteTimelinePin" to this::deleteTimelinePin,
114+
"playSound" to this::playSound,
115+
"playSoundById" to this::playSoundById,
116+
"playDefaultRingtone" to this::playDefaultRingtone,
117+
"stopSound" to this::stopSound,
49118
)
50119

51120
override val name: String = "_Pebble"
@@ -95,9 +164,157 @@ class JSCPrivatePKJSInterface(
95164
"getActivePebbleWatchInfo" -> getActivePebbleWatchInfo()
96165
"insertTimelinePin" -> { insertTimelinePin(args[0].toString()); null }
97166
"deleteTimelinePin" -> { deleteTimelinePin(args[0].toString()); null }
167+
"playSound" -> { playSound(args.getOrNull(0)?.toString() ?: "", args.getOrNull(1)?.toString() ?: ""); null }
168+
"playSoundById" -> { playSoundById(args.getOrNull(0)?.toString() ?: ""); null }
169+
"playDefaultRingtone" -> { playDefaultRingtone(); null }
170+
"stopSound" -> { stopSound(); null }
98171
else -> error("Unknown method: $method")
99172
}
100173

174+
private fun playLoopingSystemSound(soundId: UInt, remainingLoops: Int) {
175+
val generation = soundGeneration
176+
if (!isLooping || currentSystemSoundId != soundId) return
177+
if (remainingLoops <= 0) {
178+
isLooping = false
179+
currentSystemSoundId = 0u
180+
return
181+
}
182+
AudioServicesPlaySystemSoundWithCompletion(soundId) {
183+
if (isLooping && currentSystemSoundId == soundId && generation == soundGeneration) {
184+
playLoopingSystemSound(soundId, remainingLoops - 1)
185+
}
186+
}
187+
}
188+
189+
override fun stopSound() {
190+
super.stopSound()
191+
soundGeneration++
192+
isLooping = false
193+
loopType = ""
194+
loopText = ""
195+
currentSystemSoundId = 0u
196+
voiceLoopCount = 0
197+
avPlayer?.stop()
198+
avPlayer?.prepareToPlay()
199+
if (synthesizer.isSpeaking()) {
200+
synthesizer.stopSpeakingAtBoundary(platform.AVFAudio.AVSpeechBoundary.AVSpeechBoundaryImmediate)
201+
}
202+
}
203+
204+
private fun setupAudioSession() {
205+
val session = AVAudioSession.sharedInstance()
206+
session.setCategory(AVAudioSessionCategoryPlayback, withOptions = AVAudioSessionCategoryOptionDuckOthers, error = null)
207+
session.setActive(true, error = null)
208+
}
209+
210+
private fun playVoice(text: String, loop: Boolean) {
211+
setupAudioSession()
212+
val alertText = if (text.isNotBlank()) text else "Pebble Alert"
213+
if (loop) {
214+
isLooping = true
215+
loopType = "voice_loop"
216+
loopText = alertText
217+
voiceLoopCount = 0
218+
}
219+
val utterance = AVSpeechUtterance(string = alertText)
220+
utterance.setVolume(1.0f) // Ensure maximum volume for the utterance itself
221+
synthesizer.speakUtterance(utterance)
222+
}
223+
224+
private fun triggerSystemSound(soundId: UInt, loop: Boolean) {
225+
if (loop) {
226+
isLooping = true
227+
currentSystemSoundId = soundId
228+
playLoopingSystemSound(soundId, maxLoops)
229+
} else {
230+
if (soundId == 1304u) AudioServicesPlayAlertSound(soundId)
231+
else AudioServicesPlaySystemSound(soundId)
232+
}
233+
}
234+
235+
override fun playSound(type: String, text: String) {
236+
super.playSound(type, text)
237+
try {
238+
val cleanType = type.replace("\u0000", "").replace("\"", "").trim()
239+
val cleanText = text.replace("\u0000", "").replace("\"", "").trim()
240+
241+
logger.v { "playSound implementation - type: '$cleanType' (len: ${cleanType.length}), text: '$cleanText'" }
242+
243+
// Reset any previous loop
244+
stopSound()
245+
246+
when (cleanType) {
247+
"alarm", "alarm_loop" -> {
248+
logger.v { "Matched alarm branch" }
249+
triggerSystemSound(1304u, cleanType.endsWith("_loop"))
250+
}
251+
"sms", "sms_loop" -> {
252+
logger.v { "Matched sms branch" }
253+
triggerSystemSound(1000u, cleanType.endsWith("_loop"))
254+
}
255+
"voice_loop" -> {
256+
logger.v { "Matched voice_loop branch" }
257+
playVoice(cleanText, true)
258+
}
259+
"voice", "" -> {
260+
logger.v { "Matched voice branch" }
261+
playVoice(cleanText, false)
262+
}
263+
else -> {
264+
logger.v { "No match found for '$cleanType', falling back to voice" }
265+
playVoice(cleanText, false)
266+
}
267+
}
268+
} catch (e: Exception) {
269+
logger.e(e) { "Error playing sound" }
270+
}
271+
}
272+
273+
override fun playSoundById(id: String) {
274+
super.playSoundById(id)
275+
try {
276+
val cleanId = id.replace("\u0000", "").replace("\"", "").trim()
277+
if (cleanId.isEmpty()) return
278+
val soundId = cleanId.toUIntOrNull() ?: run {
279+
logger.w { "Invalid sound ID: $cleanId" }
280+
return
281+
}
282+
stopSound()
283+
triggerSystemSound(soundId, loop = false)
284+
} catch (e: Exception) {
285+
logger.e(e) { "Error playing sound by ID" }
286+
}
287+
}
288+
289+
override fun playDefaultRingtone() {
290+
super.playDefaultRingtone()
291+
try {
292+
stopSound()
293+
setupAudioSession()
294+
val player = avPlayer ?: run {
295+
val path = NSBundle.mainBundle.pathForResource("opening", "mp3") ?: run {
296+
logger.e { "opening.mp3 not found in bundle" }
297+
return
298+
}
299+
val url = NSURL.fileURLWithPath(path)
300+
val p = AVAudioPlayer(contentsOfURL = url, error = null) ?: run {
301+
logger.e { "Failed to create AVAudioPlayer" }
302+
return
303+
}
304+
p.apply {
305+
numberOfLoops = (maxLoops - 1).toLong()
306+
volume = 1.0f
307+
}
308+
}
309+
avPlayer = player
310+
player.currentTime = 0.0
311+
player.play()
312+
logger.v { "Playing default ringtone in loop" }
313+
} catch (e: Exception) {
314+
logger.e(e) { "Error playing default ringtone" }
315+
}
316+
}
317+
101318
override fun close() {
102319
// No-op
103320
}

0 commit comments

Comments
 (0)