@voltkit-rtc/react-native 0.0.0-stage → 0.1.1
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.
- package/CHANGELOG.md +121 -0
- package/LICENSE +21 -0
- package/README.md +1608 -2
- package/android/build.gradle +70 -0
- package/android/consumer-rules.pro +21 -0
- package/android/src/main/AndroidManifest.xml +25 -0
- package/android/src/main/java/com/voltkit/rtc/reactnative/AppVisibility.kt +67 -0
- package/android/src/main/java/com/voltkit/rtc/reactnative/AudioDevices.kt +67 -0
- package/android/src/main/java/com/voltkit/rtc/reactnative/AudioRouter.kt +487 -0
- package/android/src/main/java/com/voltkit/rtc/reactnative/CallServiceTypes.kt +120 -0
- package/android/src/main/java/com/voltkit/rtc/reactnative/NativeTimers.kt +106 -0
- package/android/src/main/java/com/voltkit/rtc/reactnative/NetworkWatch.kt +120 -0
- package/android/src/main/java/com/voltkit/rtc/reactnative/OutputChoice.kt +65 -0
- package/android/src/main/java/com/voltkit/rtc/reactnative/RtcConfig.kt +83 -0
- package/android/src/main/java/com/voltkit/rtc/reactnative/ScoLink.kt +61 -0
- package/android/src/main/java/com/voltkit/rtc/reactnative/VoltkitCallService.kt +287 -0
- package/android/src/main/java/com/voltkit/rtc/reactnative/VoltkitRtcInitProvider.kt +215 -0
- package/android/src/main/java/com/voltkit/rtc/reactnative/VoltkitRtcModule.kt +313 -0
- package/android/src/main/java/com/voltkit/rtc/reactnative/VoltkitRtcPackage.kt +30 -0
- package/android/src/main/res/drawable/voltkit_rtc_default_call_icon.xml +15 -0
- package/android/src/main/res/raw/voltkit_rtc_keep.xml +7 -0
- package/app.plugin.js +17 -0
- package/lib/commonjs/audio.js +215 -0
- package/lib/commonjs/audio.js.map +1 -0
- package/lib/commonjs/components.js +207 -0
- package/lib/commonjs/components.js.map +1 -0
- package/lib/commonjs/create-client.js +47 -0
- package/lib/commonjs/create-client.js.map +1 -0
- package/lib/commonjs/errors.js +99 -0
- package/lib/commonjs/errors.js.map +1 -0
- package/lib/commonjs/globals.js +61 -0
- package/lib/commonjs/globals.js.map +1 -0
- package/lib/commonjs/index.js +234 -0
- package/lib/commonjs/index.js.map +1 -0
- package/lib/commonjs/local-media.js +392 -0
- package/lib/commonjs/local-media.js.map +1 -0
- package/lib/commonjs/native-session.js +365 -0
- package/lib/commonjs/native-session.js.map +1 -0
- package/lib/commonjs/native.js +99 -0
- package/lib/commonjs/native.js.map +1 -0
- package/lib/commonjs/package.json +1 -0
- package/lib/commonjs/permissions.js +166 -0
- package/lib/commonjs/permissions.js.map +1 -0
- package/lib/commonjs/platform/android.js +126 -0
- package/lib/commonjs/platform/android.js.map +1 -0
- package/lib/commonjs/platform/common.js +138 -0
- package/lib/commonjs/platform/common.js.map +1 -0
- package/lib/commonjs/platform/devices.js +151 -0
- package/lib/commonjs/platform/devices.js.map +1 -0
- package/lib/commonjs/platform/index.js +35 -0
- package/lib/commonjs/platform/index.js.map +1 -0
- package/lib/commonjs/platform/ios.js +54 -0
- package/lib/commonjs/platform/ios.js.map +1 -0
- package/lib/commonjs/platform/streams.js +273 -0
- package/lib/commonjs/platform/streams.js.map +1 -0
- package/lib/commonjs/platform/timers.js +71 -0
- package/lib/commonjs/platform/timers.js.map +1 -0
- package/lib/commonjs/platform/visibility.js +60 -0
- package/lib/commonjs/platform/visibility.js.map +1 -0
- package/lib/commonjs/preflight.js +77 -0
- package/lib/commonjs/preflight.js.map +1 -0
- package/lib/commonjs/provider.js +75 -0
- package/lib/commonjs/provider.js.map +1 -0
- package/lib/commonjs/specs/NativeVoltkitRtc.js +29 -0
- package/lib/commonjs/specs/NativeVoltkitRtc.js.map +1 -0
- package/lib/module/audio.js +207 -0
- package/lib/module/audio.js.map +1 -0
- package/lib/module/components.js +200 -0
- package/lib/module/components.js.map +1 -0
- package/lib/module/create-client.js +42 -0
- package/lib/module/create-client.js.map +1 -0
- package/lib/module/errors.js +87 -0
- package/lib/module/errors.js.map +1 -0
- package/lib/module/globals.js +53 -0
- package/lib/module/globals.js.map +1 -0
- package/lib/module/index.js +33 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/local-media.js +387 -0
- package/lib/module/local-media.js.map +1 -0
- package/lib/module/native-session.js +360 -0
- package/lib/module/native-session.js.map +1 -0
- package/lib/module/native.js +87 -0
- package/lib/module/native.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/permissions.js +160 -0
- package/lib/module/permissions.js.map +1 -0
- package/lib/module/platform/android.js +122 -0
- package/lib/module/platform/android.js.map +1 -0
- package/lib/module/platform/common.js +128 -0
- package/lib/module/platform/common.js.map +1 -0
- package/lib/module/platform/devices.js +142 -0
- package/lib/module/platform/devices.js.map +1 -0
- package/lib/module/platform/index.js +24 -0
- package/lib/module/platform/index.js.map +1 -0
- package/lib/module/platform/ios.js +49 -0
- package/lib/module/platform/ios.js.map +1 -0
- package/lib/module/platform/streams.js +268 -0
- package/lib/module/platform/streams.js.map +1 -0
- package/lib/module/platform/timers.js +67 -0
- package/lib/module/platform/timers.js.map +1 -0
- package/lib/module/platform/visibility.js +54 -0
- package/lib/module/platform/visibility.js.map +1 -0
- package/lib/module/preflight.js +72 -0
- package/lib/module/preflight.js.map +1 -0
- package/lib/module/provider.js +70 -0
- package/lib/module/provider.js.map +1 -0
- package/lib/module/specs/NativeVoltkitRtc.js +37 -0
- package/lib/module/specs/NativeVoltkitRtc.js.map +1 -0
- package/lib/typescript/commonjs/audio.d.ts +58 -0
- package/lib/typescript/commonjs/audio.d.ts.map +1 -0
- package/lib/typescript/commonjs/components.d.ts +58 -0
- package/lib/typescript/commonjs/components.d.ts.map +1 -0
- package/lib/typescript/commonjs/create-client.d.ts +35 -0
- package/lib/typescript/commonjs/create-client.d.ts.map +1 -0
- package/lib/typescript/commonjs/errors.d.ts +34 -0
- package/lib/typescript/commonjs/errors.d.ts.map +1 -0
- package/lib/typescript/commonjs/globals.d.ts +16 -0
- package/lib/typescript/commonjs/globals.d.ts.map +1 -0
- package/lib/typescript/commonjs/index.d.ts +24 -0
- package/lib/typescript/commonjs/index.d.ts.map +1 -0
- package/lib/typescript/commonjs/local-media.d.ts +86 -0
- package/lib/typescript/commonjs/local-media.d.ts.map +1 -0
- package/lib/typescript/commonjs/native-session.d.ts +38 -0
- package/lib/typescript/commonjs/native-session.d.ts.map +1 -0
- package/lib/typescript/commonjs/native.d.ts +18 -0
- package/lib/typescript/commonjs/native.d.ts.map +1 -0
- package/lib/typescript/commonjs/package.json +1 -0
- package/lib/typescript/commonjs/permissions.d.ts +30 -0
- package/lib/typescript/commonjs/permissions.d.ts.map +1 -0
- package/lib/typescript/commonjs/platform/android.d.ts +4 -0
- package/lib/typescript/commonjs/platform/android.d.ts.map +1 -0
- package/lib/typescript/commonjs/platform/common.d.ts +101 -0
- package/lib/typescript/commonjs/platform/common.d.ts.map +1 -0
- package/lib/typescript/commonjs/platform/devices.d.ts +42 -0
- package/lib/typescript/commonjs/platform/devices.d.ts.map +1 -0
- package/lib/typescript/commonjs/platform/index.d.ts +6 -0
- package/lib/typescript/commonjs/platform/index.d.ts.map +1 -0
- package/lib/typescript/commonjs/platform/ios.d.ts +4 -0
- package/lib/typescript/commonjs/platform/ios.d.ts.map +1 -0
- package/lib/typescript/commonjs/platform/streams.d.ts +82 -0
- package/lib/typescript/commonjs/platform/streams.d.ts.map +1 -0
- package/lib/typescript/commonjs/platform/timers.d.ts +19 -0
- package/lib/typescript/commonjs/platform/timers.d.ts.map +1 -0
- package/lib/typescript/commonjs/platform/visibility.d.ts +11 -0
- package/lib/typescript/commonjs/platform/visibility.d.ts.map +1 -0
- package/lib/typescript/commonjs/preflight.d.ts +19 -0
- package/lib/typescript/commonjs/preflight.d.ts.map +1 -0
- package/lib/typescript/commonjs/provider.d.ts +35 -0
- package/lib/typescript/commonjs/provider.d.ts.map +1 -0
- package/lib/typescript/commonjs/specs/NativeVoltkitRtc.d.ts +157 -0
- package/lib/typescript/commonjs/specs/NativeVoltkitRtc.d.ts.map +1 -0
- package/lib/typescript/module/audio.d.ts +58 -0
- package/lib/typescript/module/audio.d.ts.map +1 -0
- package/lib/typescript/module/components.d.ts +58 -0
- package/lib/typescript/module/components.d.ts.map +1 -0
- package/lib/typescript/module/create-client.d.ts +35 -0
- package/lib/typescript/module/create-client.d.ts.map +1 -0
- package/lib/typescript/module/errors.d.ts +34 -0
- package/lib/typescript/module/errors.d.ts.map +1 -0
- package/lib/typescript/module/globals.d.ts +16 -0
- package/lib/typescript/module/globals.d.ts.map +1 -0
- package/lib/typescript/module/index.d.ts +24 -0
- package/lib/typescript/module/index.d.ts.map +1 -0
- package/lib/typescript/module/local-media.d.ts +86 -0
- package/lib/typescript/module/local-media.d.ts.map +1 -0
- package/lib/typescript/module/native-session.d.ts +38 -0
- package/lib/typescript/module/native-session.d.ts.map +1 -0
- package/lib/typescript/module/native.d.ts +18 -0
- package/lib/typescript/module/native.d.ts.map +1 -0
- package/lib/typescript/module/package.json +1 -0
- package/lib/typescript/module/permissions.d.ts +30 -0
- package/lib/typescript/module/permissions.d.ts.map +1 -0
- package/lib/typescript/module/platform/android.d.ts +4 -0
- package/lib/typescript/module/platform/android.d.ts.map +1 -0
- package/lib/typescript/module/platform/common.d.ts +101 -0
- package/lib/typescript/module/platform/common.d.ts.map +1 -0
- package/lib/typescript/module/platform/devices.d.ts +42 -0
- package/lib/typescript/module/platform/devices.d.ts.map +1 -0
- package/lib/typescript/module/platform/index.d.ts +6 -0
- package/lib/typescript/module/platform/index.d.ts.map +1 -0
- package/lib/typescript/module/platform/ios.d.ts +4 -0
- package/lib/typescript/module/platform/ios.d.ts.map +1 -0
- package/lib/typescript/module/platform/streams.d.ts +82 -0
- package/lib/typescript/module/platform/streams.d.ts.map +1 -0
- package/lib/typescript/module/platform/timers.d.ts +19 -0
- package/lib/typescript/module/platform/timers.d.ts.map +1 -0
- package/lib/typescript/module/platform/visibility.d.ts +11 -0
- package/lib/typescript/module/platform/visibility.d.ts.map +1 -0
- package/lib/typescript/module/preflight.d.ts +19 -0
- package/lib/typescript/module/preflight.d.ts.map +1 -0
- package/lib/typescript/module/provider.d.ts +35 -0
- package/lib/typescript/module/provider.d.ts.map +1 -0
- package/lib/typescript/module/specs/NativeVoltkitRtc.d.ts +157 -0
- package/lib/typescript/module/specs/NativeVoltkitRtc.d.ts.map +1 -0
- package/package.json +140 -4
- package/plugin/build/android.d.ts +36 -0
- package/plugin/build/android.js +265 -0
- package/plugin/build/index.d.ts +9 -0
- package/plugin/build/index.js +54 -0
- package/plugin/build/ios.d.ts +4 -0
- package/plugin/build/ios.js +43 -0
- package/plugin/build/options.d.ts +93 -0
- package/plugin/build/options.js +140 -0
- package/react-native.config.js +11 -0
- package/src/audio.ts +221 -0
- package/src/components.tsx +233 -0
- package/src/create-client.ts +53 -0
- package/src/errors.ts +81 -0
- package/src/globals.ts +54 -0
- package/src/index.ts +95 -0
- package/src/local-media.ts +420 -0
- package/src/native-session.tsx +384 -0
- package/src/native.ts +84 -0
- package/src/permissions.ts +173 -0
- package/src/platform/android.ts +128 -0
- package/src/platform/common.ts +157 -0
- package/src/platform/devices.ts +148 -0
- package/src/platform/index.ts +22 -0
- package/src/platform/ios.ts +48 -0
- package/src/platform/streams.ts +285 -0
- package/src/platform/timers.ts +72 -0
- package/src/platform/visibility.ts +59 -0
- package/src/preflight.ts +60 -0
- package/src/provider.tsx +88 -0
- package/src/specs/NativeVoltkitRtc.ts +180 -0
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
package com.voltkit.rtc.reactnative
|
|
2
|
+
|
|
3
|
+
import android.annotation.SuppressLint
|
|
4
|
+
import android.content.BroadcastReceiver
|
|
5
|
+
import android.content.Context
|
|
6
|
+
import android.content.Intent
|
|
7
|
+
import android.content.IntentFilter
|
|
8
|
+
import android.media.AudioAttributes
|
|
9
|
+
import android.media.AudioDeviceCallback
|
|
10
|
+
import android.media.AudioDeviceInfo
|
|
11
|
+
import android.media.AudioFocusRequest
|
|
12
|
+
import android.media.AudioManager
|
|
13
|
+
import android.os.Build
|
|
14
|
+
import android.os.Handler
|
|
15
|
+
import android.os.HandlerThread
|
|
16
|
+
import android.os.Looper
|
|
17
|
+
import android.util.Log
|
|
18
|
+
import java.util.concurrent.CopyOnWriteArraySet
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The device's audio for calls: the audio mode, audio focus and where sound plays, shared by every client in the
|
|
22
|
+
* process because `AudioManager` is process-wide. Each connected client that manages audio holds it; the last to let
|
|
23
|
+
* go puts the device back as it found it. The routing rules are those of Twilio's AudioSwitch, which LiveKit and
|
|
24
|
+
* flutter_webrtc use: a selected device while it is connected, else a Bluetooth headset, else a wired or USB headset,
|
|
25
|
+
* else the loudspeaker or the earpiece as preferred. Everything runs on its own thread. No Bluetooth permission is
|
|
26
|
+
* needed (communication devices and SCO are AudioManager's).
|
|
27
|
+
*
|
|
28
|
+
* A line-by-line port of the Android SDK's AudioRouter (android-sdk/voltkit-rtc/src/main/kotlin/com/voltkit/rtc/
|
|
29
|
+
* AudioRouter.kt), React Native's differences being only these:
|
|
30
|
+
* - a holder is a token (one per JavaScript client, `audioAcquire`), and listening is separate: the module listens once;
|
|
31
|
+
* - answers come back through callbacks (the module's promises), not coroutines;
|
|
32
|
+
* - audio focus changes are told to the listeners, so a phone call can be reported as taking the microphone;
|
|
33
|
+
* - the speaker preference given at acquire may be null, keeping the one `setSpeakerphoneOn` set before any client.
|
|
34
|
+
*/
|
|
35
|
+
internal class AudioRouter private constructor(context: Context, private val mode: AudioMode) {
|
|
36
|
+
interface Listener {
|
|
37
|
+
fun onDevicesChanged(change: DevicesChanged)
|
|
38
|
+
fun onOutputChanged(device: MediaDevice?)
|
|
39
|
+
fun onFocusChanged(focus: String)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private class Saved(val mode: Int, val speakerphone: Boolean, val micMute: Boolean)
|
|
43
|
+
|
|
44
|
+
private val app = context.applicationContext
|
|
45
|
+
private val am = app.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
|
46
|
+
private val thread = HandlerThread("VoltKitAudio").apply { start() }
|
|
47
|
+
private val handler = Handler(thread.looper)
|
|
48
|
+
private val listeners = CopyOnWriteArraySet<Listener>()
|
|
49
|
+
|
|
50
|
+
// Touched only on the router thread.
|
|
51
|
+
private val holders = HashSet<Any>()
|
|
52
|
+
private var saved: Saved? = null
|
|
53
|
+
private var focusRequest: AudioFocusRequest? = null
|
|
54
|
+
private val choice = OutputChoice()
|
|
55
|
+
private val sco = ScoLink()
|
|
56
|
+
private var watching = false
|
|
57
|
+
private var scanned = false
|
|
58
|
+
private val infos = HashMap<String, AudioDeviceInfo>()
|
|
59
|
+
|
|
60
|
+
/** Every audio device now; refreshed as they come and go while held. */
|
|
61
|
+
@Volatile
|
|
62
|
+
var devices: List<MediaDevice> = emptyList()
|
|
63
|
+
private set
|
|
64
|
+
|
|
65
|
+
/** Where call audio plays now, when the SDK routes it. */
|
|
66
|
+
@Volatile
|
|
67
|
+
var output: MediaDevice? = null
|
|
68
|
+
private set
|
|
69
|
+
|
|
70
|
+
val isRouting: Boolean get() = mode == AudioMode.communication
|
|
71
|
+
|
|
72
|
+
fun addListener(listener: Listener) {
|
|
73
|
+
listeners.add(listener)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
fun removeListener(listener: Listener) {
|
|
77
|
+
listeners.remove(listener)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// --- the clients' side ------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* A client connected and manages audio: hold the call audio set up while it is. [speakerphone] sets the preference
|
|
84
|
+
* (null keeps it). [done] runs on the router thread once the audio is set up, with where it plays.
|
|
85
|
+
*/
|
|
86
|
+
fun acquire(holder: Any, speakerphone: Boolean?, done: (MediaDevice?) -> Unit = {}) = post {
|
|
87
|
+
if (holders.add(holder)) {
|
|
88
|
+
if (speakerphone != null) choice.speakerPreferred = speakerphone
|
|
89
|
+
if (holders.size == 1) activate() else refresh(route = true)
|
|
90
|
+
}
|
|
91
|
+
done(output)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** That client left; the last one out restores the device's audio. */
|
|
95
|
+
fun release(holder: Any) = post {
|
|
96
|
+
if (!holders.remove(holder)) return@post
|
|
97
|
+
if (holders.isEmpty()) deactivate()
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Loudspeaker (true) or earpiece (false) when no headset is connected; a headset still wins. */
|
|
101
|
+
fun setSpeakerphoneOn(on: Boolean) = post {
|
|
102
|
+
choice.preferSpeaker(on)
|
|
103
|
+
if (holders.isNotEmpty()) refresh(route = true)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Play call audio on [deviceId] while it stays connected. False when there is no such output. */
|
|
107
|
+
fun selectOutput(deviceId: String, done: (Result<Boolean>) -> Unit) = call(done) {
|
|
108
|
+
if (devices.isEmpty() || devices.none { it.deviceId == deviceId }) refresh(route = false)
|
|
109
|
+
if (devices.none { it.kind == DeviceKind.audiooutput && it.deviceId == deviceId }) return@call false
|
|
110
|
+
choice.pick(deviceId)
|
|
111
|
+
if (holders.isNotEmpty()) refresh(route = true)
|
|
112
|
+
true
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The microphone to record from; null when there is no such input. */
|
|
116
|
+
fun inputInfo(deviceId: String, done: (Result<AudioDeviceInfo?>) -> Unit) = call(done) {
|
|
117
|
+
if (devices.none { it.deviceId == deviceId }) refresh(route = false)
|
|
118
|
+
infos[deviceId]?.takeIf { devices.any { d -> d.kind == DeviceKind.audioinput && d.deviceId == deviceId } }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The audio devices now (scanned on demand when nobody holds the router). */
|
|
122
|
+
fun list(done: (Result<List<MediaDevice>>) -> Unit) = call(done) {
|
|
123
|
+
if (holders.isEmpty() || devices.isEmpty()) refresh(route = false)
|
|
124
|
+
devices
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// --- activation -------------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
private fun activate() {
|
|
130
|
+
saved = Saved(am.mode, isSpeakerphoneOnCompat(), am.isMicrophoneMute)
|
|
131
|
+
requestFocus()
|
|
132
|
+
if (mode == AudioMode.communication) {
|
|
133
|
+
try {
|
|
134
|
+
am.mode = AudioManager.MODE_IN_COMMUNICATION
|
|
135
|
+
} catch (t: Throwable) {
|
|
136
|
+
Log.w(TAG, "could not set the call audio mode: ${t.message}")
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
// Another app may have left the microphone muted for the whole device.
|
|
140
|
+
if (am.isMicrophoneMute) am.isMicrophoneMute = false
|
|
141
|
+
watch(true)
|
|
142
|
+
refresh(route = true)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private fun deactivate() {
|
|
146
|
+
watch(false)
|
|
147
|
+
stopSco()
|
|
148
|
+
if (mode == AudioMode.communication && Build.VERSION.SDK_INT >= 31) {
|
|
149
|
+
runCatching { am.clearCommunicationDevice() }
|
|
150
|
+
}
|
|
151
|
+
saved?.let { s ->
|
|
152
|
+
runCatching { am.mode = s.mode }
|
|
153
|
+
if (Build.VERSION.SDK_INT < 31) setSpeakerphoneCompat(s.speakerphone)
|
|
154
|
+
runCatching { am.isMicrophoneMute = s.micMute }
|
|
155
|
+
}
|
|
156
|
+
saved = null
|
|
157
|
+
abandonFocus()
|
|
158
|
+
choice.reset()
|
|
159
|
+
setOutput(null)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private fun requestFocus() {
|
|
163
|
+
val usage = if (mode == AudioMode.communication) AudioAttributes.USAGE_VOICE_COMMUNICATION else AudioAttributes.USAGE_MEDIA
|
|
164
|
+
val content = if (mode == AudioMode.communication) AudioAttributes.CONTENT_TYPE_SPEECH else AudioAttributes.CONTENT_TYPE_MUSIC
|
|
165
|
+
try {
|
|
166
|
+
if (Build.VERSION.SDK_INT >= 26) {
|
|
167
|
+
val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
|
|
168
|
+
.setAudioAttributes(AudioAttributes.Builder().setUsage(usage).setContentType(content).build())
|
|
169
|
+
.setAcceptsDelayedFocusGain(false)
|
|
170
|
+
.setOnAudioFocusChangeListener(focusListener, handler)
|
|
171
|
+
.build()
|
|
172
|
+
focusRequest = request
|
|
173
|
+
am.requestAudioFocus(request)
|
|
174
|
+
} else {
|
|
175
|
+
@Suppress("DEPRECATION")
|
|
176
|
+
am.requestAudioFocus(
|
|
177
|
+
focusListener,
|
|
178
|
+
if (mode == AudioMode.communication) AudioManager.STREAM_VOICE_CALL else AudioManager.STREAM_MUSIC,
|
|
179
|
+
AudioManager.AUDIOFOCUS_GAIN,
|
|
180
|
+
)
|
|
181
|
+
}
|
|
182
|
+
} catch (t: Throwable) {
|
|
183
|
+
Log.w(TAG, "audio focus request failed: ${t.message}")
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private fun abandonFocus() {
|
|
188
|
+
try {
|
|
189
|
+
if (Build.VERSION.SDK_INT >= 26) {
|
|
190
|
+
focusRequest?.let { am.abandonAudioFocusRequest(it) }
|
|
191
|
+
} else {
|
|
192
|
+
@Suppress("DEPRECATION")
|
|
193
|
+
am.abandonAudioFocus(focusListener)
|
|
194
|
+
}
|
|
195
|
+
} catch (_: Throwable) {
|
|
196
|
+
}
|
|
197
|
+
focusRequest = null
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// A call keeps its audio when a notification or another app's sound takes focus: nothing to change here. The
|
|
201
|
+
// listeners hear it, so a phone call taking the microphone can be reported.
|
|
202
|
+
private val focusListener = AudioManager.OnAudioFocusChangeListener { change ->
|
|
203
|
+
Log.d(TAG, "audio focus changed: $change")
|
|
204
|
+
val focus = focusName(change) ?: return@OnAudioFocusChangeListener
|
|
205
|
+
listeners.forEach { runCatching { it.onFocusChanged(focus) } }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// --- watching devices -------------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
private val deviceCallback = object : AudioDeviceCallback() {
|
|
211
|
+
override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>) {
|
|
212
|
+
choice.devicesAdded()
|
|
213
|
+
refresh(route = holders.isNotEmpty())
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
override fun onAudioDevicesRemoved(removedDevices: Array<out AudioDeviceInfo>) = refresh(route = holders.isNotEmpty())
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private val commDeviceListener: Any? =
|
|
220
|
+
if (Build.VERSION.SDK_INT >= 31) {
|
|
221
|
+
AudioManager.OnCommunicationDeviceChangedListener { info ->
|
|
222
|
+
handler.post { if (holders.isNotEmpty()) setOutput(info?.let { outputOf(it) }) }
|
|
223
|
+
}
|
|
224
|
+
} else {
|
|
225
|
+
null
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
private val scoReceiver = object : BroadcastReceiver() {
|
|
229
|
+
override fun onReceive(context: Context, intent: Intent) {
|
|
230
|
+
// The broadcast is sticky: registering replays the last call's state
|
|
231
|
+
// (usually DISCONNECTED), which says nothing about the link just asked for.
|
|
232
|
+
if (isInitialStickyBroadcast) return
|
|
233
|
+
val state = intent.getIntExtra(AudioManager.EXTRA_SCO_AUDIO_STATE, AudioManager.SCO_AUDIO_STATE_ERROR)
|
|
234
|
+
if (sco.failed(state)) scoFailed()
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** The headset refused the call link: route elsewhere until devices change. */
|
|
239
|
+
private fun scoFailed() {
|
|
240
|
+
choice.refusedBy(output?.deviceId)
|
|
241
|
+
Log.w(TAG, "the Bluetooth headset did not open its call audio; routing elsewhere")
|
|
242
|
+
stopSco()
|
|
243
|
+
refresh(route = holders.isNotEmpty())
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
@SuppressLint("UnspecifiedRegisterReceiverFlag")
|
|
247
|
+
private fun watch(on: Boolean) {
|
|
248
|
+
if (on == watching) return
|
|
249
|
+
watching = on
|
|
250
|
+
try {
|
|
251
|
+
if (on) {
|
|
252
|
+
am.registerAudioDeviceCallback(deviceCallback, handler)
|
|
253
|
+
// Only call audio has a communication device. In media mode sound follows the system's media
|
|
254
|
+
// routing, and the call's device (often the earpiece) is not where it plays (SR-3).
|
|
255
|
+
if (Build.VERSION.SDK_INT >= 31 && mode == AudioMode.communication) {
|
|
256
|
+
am.addOnCommunicationDeviceChangedListener(
|
|
257
|
+
{ r -> handler.post(r) },
|
|
258
|
+
commDeviceListener as AudioManager.OnCommunicationDeviceChangedListener,
|
|
259
|
+
)
|
|
260
|
+
} else if (Build.VERSION.SDK_INT < 31 && mode == AudioMode.communication) {
|
|
261
|
+
app.registerReceiver(scoReceiver, IntentFilter(AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED), null, handler)
|
|
262
|
+
}
|
|
263
|
+
} else {
|
|
264
|
+
am.unregisterAudioDeviceCallback(deviceCallback)
|
|
265
|
+
if (Build.VERSION.SDK_INT >= 31 && mode == AudioMode.communication) {
|
|
266
|
+
am.removeOnCommunicationDeviceChangedListener(commDeviceListener as AudioManager.OnCommunicationDeviceChangedListener)
|
|
267
|
+
} else if (Build.VERSION.SDK_INT < 31 && mode == AudioMode.communication) {
|
|
268
|
+
runCatching { app.unregisterReceiver(scoReceiver) }
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
} catch (t: Throwable) {
|
|
272
|
+
Log.w(TAG, "audio device watch ${if (on) "start" else "stop"} failed: ${t.message}")
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// --- the device list and routing ---------------------------------------------------------------
|
|
277
|
+
|
|
278
|
+
private fun refresh(route: Boolean) {
|
|
279
|
+
val before = devices
|
|
280
|
+
val now = scan()
|
|
281
|
+
devices = now
|
|
282
|
+
// The first scan is the starting list, not a change.
|
|
283
|
+
val first = !scanned
|
|
284
|
+
scanned = true
|
|
285
|
+
if (!first && before != now) {
|
|
286
|
+
val change = DevicesChanged.between(before, now)
|
|
287
|
+
if (change.added.isNotEmpty() || change.removed.isNotEmpty()) listeners.forEach { runCatching { it.onDevicesChanged(change) } }
|
|
288
|
+
}
|
|
289
|
+
val outputs = now.filter { it.kind == DeviceKind.audiooutput }
|
|
290
|
+
choice.outputsNow(outputs)
|
|
291
|
+
if (route && holders.isNotEmpty()) applyRoute(choice.choose(outputs))
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
private fun scan(): List<MediaDevice> {
|
|
295
|
+
infos.clear()
|
|
296
|
+
val outs: List<AudioDeviceInfo> = try {
|
|
297
|
+
if (Build.VERSION.SDK_INT >= 31 && mode == AudioMode.communication) {
|
|
298
|
+
am.availableCommunicationDevices
|
|
299
|
+
} else {
|
|
300
|
+
am.getDevices(AudioManager.GET_DEVICES_OUTPUTS).toList()
|
|
301
|
+
}
|
|
302
|
+
} catch (_: Throwable) {
|
|
303
|
+
emptyList()
|
|
304
|
+
}
|
|
305
|
+
val ins: List<AudioDeviceInfo> = try {
|
|
306
|
+
am.getDevices(AudioManager.GET_DEVICES_INPUTS).toList()
|
|
307
|
+
} catch (_: Throwable) {
|
|
308
|
+
emptyList()
|
|
309
|
+
}
|
|
310
|
+
val outputs = LinkedHashMap<String, MediaDevice>()
|
|
311
|
+
for (info in outs) {
|
|
312
|
+
val d = outputOf(info) ?: continue
|
|
313
|
+
if (d.deviceId !in outputs) {
|
|
314
|
+
outputs[d.deviceId] = d
|
|
315
|
+
infos[d.deviceId] = info
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
// Before Android 12 the earpiece cannot be chosen over a wired headset: it is not offered then.
|
|
319
|
+
if (Build.VERSION.SDK_INT < 31 && outputs.values.any { it.type == AudioDeviceType.wiredHeadset }) outputs.remove(EARPIECE)
|
|
320
|
+
val inputs = LinkedHashMap<String, MediaDevice>()
|
|
321
|
+
for (info in ins) {
|
|
322
|
+
val d = inputOf(info) ?: continue
|
|
323
|
+
if (d.deviceId !in inputs) {
|
|
324
|
+
inputs[d.deviceId] = d
|
|
325
|
+
infos[d.deviceId] = info
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
val order = listOf(AudioDeviceType.bluetooth, AudioDeviceType.wiredHeadset, AudioDeviceType.usb, AudioDeviceType.earpiece, AudioDeviceType.speaker)
|
|
329
|
+
return outputs.values.sortedBy { order.indexOf(it.type).let { i -> if (i < 0) order.size else i } } + inputs.values
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
private fun outputOf(info: AudioDeviceInfo): MediaDevice? {
|
|
333
|
+
val name = info.productName?.toString()?.trim().orEmpty()
|
|
334
|
+
return when (info.type) {
|
|
335
|
+
AudioDeviceInfo.TYPE_BUILTIN_SPEAKER -> MediaDevice(SPEAKER, DeviceKind.audiooutput, "Speaker", AudioDeviceType.speaker)
|
|
336
|
+
AudioDeviceInfo.TYPE_BUILTIN_EARPIECE ->
|
|
337
|
+
if (mode == AudioMode.communication) MediaDevice(EARPIECE, DeviceKind.audiooutput, "Phone", AudioDeviceType.earpiece) else null
|
|
338
|
+
AudioDeviceInfo.TYPE_WIRED_HEADSET, AudioDeviceInfo.TYPE_WIRED_HEADPHONES ->
|
|
339
|
+
MediaDevice("wired", DeviceKind.audiooutput, "Wired headset", AudioDeviceType.wiredHeadset, groupId = "wired")
|
|
340
|
+
AudioDeviceInfo.TYPE_USB_HEADSET, AudioDeviceInfo.TYPE_USB_DEVICE ->
|
|
341
|
+
MediaDevice("usb:${info.id}", DeviceKind.audiooutput, name.ifEmpty { "USB audio" }, AudioDeviceType.usb, groupId = "usb:$name")
|
|
342
|
+
AudioDeviceInfo.TYPE_BLUETOOTH_SCO ->
|
|
343
|
+
if (mode == AudioMode.communication) bluetooth(info, name) else null
|
|
344
|
+
// In media mode a Bluetooth headset plays in its high-quality (A2DP) profile.
|
|
345
|
+
AudioDeviceInfo.TYPE_BLUETOOTH_A2DP ->
|
|
346
|
+
if (mode == AudioMode.media) bluetooth(info, name) else null
|
|
347
|
+
else -> when {
|
|
348
|
+
Build.VERSION.SDK_INT >= 31 && (info.type == AudioDeviceInfo.TYPE_BLE_HEADSET || info.type == AudioDeviceInfo.TYPE_BLE_SPEAKER) -> bluetooth(info, name)
|
|
349
|
+
Build.VERSION.SDK_INT >= 28 && info.type == AudioDeviceInfo.TYPE_HEARING_AID -> bluetooth(info, name.ifEmpty { "Hearing aid" })
|
|
350
|
+
else -> null
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
private fun bluetooth(info: AudioDeviceInfo, name: String) =
|
|
356
|
+
MediaDevice("bluetooth:${info.id}", DeviceKind.audiooutput, name.ifEmpty { "Bluetooth" }, AudioDeviceType.bluetooth, groupId = "bluetooth:$name")
|
|
357
|
+
|
|
358
|
+
private fun inputOf(info: AudioDeviceInfo): MediaDevice? {
|
|
359
|
+
val name = info.productName?.toString()?.trim().orEmpty()
|
|
360
|
+
return when (info.type) {
|
|
361
|
+
AudioDeviceInfo.TYPE_BUILTIN_MIC -> MediaDevice(MIC, DeviceKind.audioinput, "Phone microphone", AudioDeviceType.builtinMic)
|
|
362
|
+
AudioDeviceInfo.TYPE_WIRED_HEADSET ->
|
|
363
|
+
MediaDevice("wired-mic", DeviceKind.audioinput, "Headset microphone", AudioDeviceType.wiredHeadset, groupId = "wired")
|
|
364
|
+
AudioDeviceInfo.TYPE_USB_HEADSET, AudioDeviceInfo.TYPE_USB_DEVICE ->
|
|
365
|
+
MediaDevice("usb-mic:${info.id}", DeviceKind.audioinput, name.ifEmpty { "USB microphone" }, AudioDeviceType.usb, groupId = "usb:$name")
|
|
366
|
+
AudioDeviceInfo.TYPE_BLUETOOTH_SCO ->
|
|
367
|
+
MediaDevice("bluetooth-mic:${info.id}", DeviceKind.audioinput, name.ifEmpty { "Bluetooth" }, AudioDeviceType.bluetooth, groupId = "bluetooth:$name")
|
|
368
|
+
else -> if (Build.VERSION.SDK_INT >= 31 && info.type == AudioDeviceInfo.TYPE_BLE_HEADSET) {
|
|
369
|
+
MediaDevice("bluetooth-mic:${info.id}", DeviceKind.audioinput, name.ifEmpty { "Bluetooth" }, AudioDeviceType.bluetooth, groupId = "bluetooth:$name")
|
|
370
|
+
} else {
|
|
371
|
+
null
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
private fun applyRoute(target: MediaDevice?) {
|
|
377
|
+
if (target == null) return
|
|
378
|
+
if (mode != AudioMode.communication) {
|
|
379
|
+
// Media playback follows the system's own routing.
|
|
380
|
+
setOutput(target)
|
|
381
|
+
return
|
|
382
|
+
}
|
|
383
|
+
try {
|
|
384
|
+
if (Build.VERSION.SDK_INT >= 31) {
|
|
385
|
+
val info = infos[target.deviceId] ?: return
|
|
386
|
+
if (am.communicationDevice?.id != info.id && !am.setCommunicationDevice(info)) {
|
|
387
|
+
Log.w(TAG, "could not route call audio to ${target.label}")
|
|
388
|
+
return
|
|
389
|
+
}
|
|
390
|
+
} else {
|
|
391
|
+
when (target.type) {
|
|
392
|
+
AudioDeviceType.bluetooth -> {
|
|
393
|
+
setSpeakerphoneCompat(false)
|
|
394
|
+
startSco()
|
|
395
|
+
}
|
|
396
|
+
AudioDeviceType.speaker -> {
|
|
397
|
+
stopSco()
|
|
398
|
+
setSpeakerphoneCompat(true)
|
|
399
|
+
}
|
|
400
|
+
else -> {
|
|
401
|
+
stopSco()
|
|
402
|
+
setSpeakerphoneCompat(false)
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
} catch (t: Throwable) {
|
|
407
|
+
Log.w(TAG, "routing call audio to ${target.label} failed: ${t.message}")
|
|
408
|
+
return
|
|
409
|
+
}
|
|
410
|
+
setOutput(target)
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
@Suppress("DEPRECATION")
|
|
414
|
+
private fun startSco() {
|
|
415
|
+
if (sco.wanted) return
|
|
416
|
+
val start = sco.start()
|
|
417
|
+
runCatching {
|
|
418
|
+
am.startBluetoothSco()
|
|
419
|
+
am.isBluetoothScoOn = true
|
|
420
|
+
}.onFailure { Log.w(TAG, "Bluetooth call audio could not start: ${it.message}") }
|
|
421
|
+
// A refusal can say only DISCONNECTED, which ScoLink takes for the link stopped before: silence is a refusal too.
|
|
422
|
+
handler.postDelayed({ if (sco.silent(start)) scoFailed() }, ScoLink.START_TIMEOUT_MILLIS)
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
@Suppress("DEPRECATION")
|
|
426
|
+
private fun stopSco() {
|
|
427
|
+
if (!sco.wanted) return
|
|
428
|
+
sco.stop()
|
|
429
|
+
runCatching {
|
|
430
|
+
am.isBluetoothScoOn = false
|
|
431
|
+
am.stopBluetoothSco()
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
@Suppress("DEPRECATION")
|
|
436
|
+
private fun isSpeakerphoneOnCompat(): Boolean = runCatching { am.isSpeakerphoneOn }.getOrDefault(false)
|
|
437
|
+
|
|
438
|
+
@Suppress("DEPRECATION")
|
|
439
|
+
private fun setSpeakerphoneCompat(on: Boolean) {
|
|
440
|
+
runCatching { if (am.isSpeakerphoneOn != on) am.isSpeakerphoneOn = on }
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
private fun setOutput(device: MediaDevice?) {
|
|
444
|
+
if (output == device) return
|
|
445
|
+
output = device
|
|
446
|
+
listeners.forEach { runCatching { it.onOutputChanged(device) } }
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// --- threading --------------------------------------------------------------------------------
|
|
450
|
+
|
|
451
|
+
private fun post(block: () -> Unit) {
|
|
452
|
+
if (Looper.myLooper() == thread.looper) block() else handler.post(block)
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** Run [block] on the router thread and hand its result (or what it threw) to [done], on that thread. */
|
|
456
|
+
private fun <T> call(done: (Result<T>) -> Unit, block: () -> T) {
|
|
457
|
+
val run = Runnable { done(runCatching(block)) }
|
|
458
|
+
if (Looper.myLooper() == thread.looper) run.run() else handler.post(run)
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
companion object {
|
|
462
|
+
private const val TAG = "VoltKitRtc"
|
|
463
|
+
const val SPEAKER = "speaker"
|
|
464
|
+
const val EARPIECE = "earpiece"
|
|
465
|
+
const val MIC = "mic"
|
|
466
|
+
|
|
467
|
+
@Volatile
|
|
468
|
+
private var shared: AudioRouter? = null
|
|
469
|
+
|
|
470
|
+
/** The process's router. Its audio mode is the process's ([RtcConfig.audioMode]): the first caller's wins. */
|
|
471
|
+
fun get(context: Context, mode: AudioMode): AudioRouter =
|
|
472
|
+
shared ?: synchronized(this) { shared ?: AudioRouter(context.applicationContext, mode).also { shared = it } }
|
|
473
|
+
|
|
474
|
+
/** The router, if one was made. */
|
|
475
|
+
fun existing(): AudioRouter? = shared
|
|
476
|
+
|
|
477
|
+
/** AudioManager.AUDIOFOCUS_* by the names JavaScript receives. */
|
|
478
|
+
fun focusName(change: Int): String? = when (change) {
|
|
479
|
+
AudioManager.AUDIOFOCUS_GAIN, AudioManager.AUDIOFOCUS_GAIN_TRANSIENT,
|
|
480
|
+
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE, AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK -> "gain"
|
|
481
|
+
AudioManager.AUDIOFOCUS_LOSS -> "loss"
|
|
482
|
+
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> "lossTransient"
|
|
483
|
+
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> "lossTransientCanDuck"
|
|
484
|
+
else -> null
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
package com.voltkit.rtc.reactnative
|
|
2
|
+
|
|
3
|
+
import android.Manifest
|
|
4
|
+
import android.content.pm.ServiceInfo
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Which foreground service types the call service runs with (plan §5.4, PLAN_UPDATES 5): a pure function of what the
|
|
8
|
+
* app's manifest declares, what is granted, what the call publishes and the audio mode, so every Android version's
|
|
9
|
+
* rules are tested off a device (CallServiceTypesTest). Android's rules
|
|
10
|
+
* (developer.android.com/develop/background-work/services/fgs/service-types):
|
|
11
|
+
* - API 28+: FOREGROUND_SERVICE, for any foreground service.
|
|
12
|
+
* - API 29: the types exist, but microphone and camera only come in API 30.
|
|
13
|
+
* - API 30+: a service started while the app is visible keeps the microphone (camera) in the background only with the
|
|
14
|
+
* microphone (camera) type.
|
|
15
|
+
* - API 34+: each type needs its FOREGROUND_SERVICE_<TYPE> permission; microphone needs RECORD_AUDIO granted, camera
|
|
16
|
+
* CAMERA; mediaProjection the user's consent to a share; and a service must name at least one type.
|
|
17
|
+
*
|
|
18
|
+
* The choice: microphone when RECORD_AUDIO is granted, otherwise mediaPlayback (a listener); a listen-only app (media
|
|
19
|
+
* mode) plays media unless it publishes the microphone. Camera only when the app keeps the camera in the background
|
|
20
|
+
* (pauseCamera false) and publishes it. mediaProjection only while a share is live. The types only ever widen while
|
|
21
|
+
* the app is visible: the JavaScript side starts and updates the service then.
|
|
22
|
+
*/
|
|
23
|
+
internal object CallServiceTypes {
|
|
24
|
+
const val MEDIA_PLAYBACK = ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK
|
|
25
|
+
const val MEDIA_PROJECTION = ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION
|
|
26
|
+
const val CAMERA = ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA
|
|
27
|
+
const val MICROPHONE = ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
|
|
28
|
+
|
|
29
|
+
data class Input(
|
|
30
|
+
val sdkInt: Int,
|
|
31
|
+
/** The service's android:foregroundServiceType (0 below API 29, or when it declares none). */
|
|
32
|
+
val declaredTypes: Int,
|
|
33
|
+
/** The permissions granted now: runtime ones the user allowed, and normal ones the manifest declares. */
|
|
34
|
+
val granted: Set<String>,
|
|
35
|
+
val audioMode: AudioMode,
|
|
36
|
+
val micPublished: Boolean,
|
|
37
|
+
val cameraPublished: Boolean,
|
|
38
|
+
/** The provider's background.pauseCamera: the camera stops in the background, so no camera type. */
|
|
39
|
+
val pauseCamera: Boolean,
|
|
40
|
+
/** A screen share is live. */
|
|
41
|
+
val sharing: Boolean,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
data class Decision(
|
|
45
|
+
/** For startForeground: 0 below API 29, where there are no types. */
|
|
46
|
+
val types: Int,
|
|
47
|
+
val canStart: Boolean,
|
|
48
|
+
/** Why it cannot start; null when it can. */
|
|
49
|
+
val reason: String?,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
fun decide(input: Input): Decision {
|
|
53
|
+
val granted = input.granted
|
|
54
|
+
val sdk = input.sdkInt
|
|
55
|
+
if (sdk >= 28 && Manifest.permission.FOREGROUND_SERVICE !in granted) {
|
|
56
|
+
return Decision(0, false, "the app's manifest does not declare android.permission.FOREGROUND_SERVICE")
|
|
57
|
+
}
|
|
58
|
+
if (sdk < 29) return Decision(0, true, null)
|
|
59
|
+
|
|
60
|
+
fun declared(type: Int) = input.declaredTypes and type == type
|
|
61
|
+
fun permitted(permission: String) = sdk < 34 || permission in granted
|
|
62
|
+
|
|
63
|
+
val mic = sdk >= 30 && declared(MICROPHONE) && Manifest.permission.RECORD_AUDIO in granted &&
|
|
64
|
+
permitted(Manifest.permission.FOREGROUND_SERVICE_MICROPHONE)
|
|
65
|
+
val playback = declared(MEDIA_PLAYBACK) && permitted(Manifest.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK)
|
|
66
|
+
val camera = sdk >= 30 && !input.pauseCamera && input.cameraPublished && declared(CAMERA) &&
|
|
67
|
+
Manifest.permission.CAMERA in granted && permitted(Manifest.permission.FOREGROUND_SERVICE_CAMERA)
|
|
68
|
+
val projection = input.sharing && declared(MEDIA_PROJECTION) &&
|
|
69
|
+
permitted(Manifest.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION)
|
|
70
|
+
|
|
71
|
+
val audio = when (input.audioMode) {
|
|
72
|
+
AudioMode.communication -> if (mic) MICROPHONE else if (playback) MEDIA_PLAYBACK else 0
|
|
73
|
+
AudioMode.media -> if (mic && input.micPublished) MICROPHONE else if (playback) MEDIA_PLAYBACK else if (mic) MICROPHONE else 0
|
|
74
|
+
}
|
|
75
|
+
val types = audio or (if (camera) CAMERA else 0) or (if (projection) MEDIA_PROJECTION else 0)
|
|
76
|
+
// From Android 14 a foreground service must name a type; below, none keeps the process and its network.
|
|
77
|
+
if (types == 0 && sdk >= 34) return Decision(0, false, missing(input))
|
|
78
|
+
return Decision(types, true, null)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** What keeps the service from running on Android 14 and later, in the words of the manifest. */
|
|
82
|
+
private fun missing(input: Input): String {
|
|
83
|
+
val granted = input.granted
|
|
84
|
+
val wantsMic = input.declaredTypes and MICROPHONE != 0
|
|
85
|
+
val wantsPlayback = input.declaredTypes and MEDIA_PLAYBACK != 0
|
|
86
|
+
if (!wantsMic && !wantsPlayback) {
|
|
87
|
+
return "VoltkitCallService's android:foregroundServiceType declares neither microphone nor mediaPlayback"
|
|
88
|
+
}
|
|
89
|
+
val needs = ArrayList<String>()
|
|
90
|
+
if (wantsMic) {
|
|
91
|
+
if (Manifest.permission.RECORD_AUDIO !in granted) needs += "android.permission.RECORD_AUDIO granted"
|
|
92
|
+
if (Manifest.permission.FOREGROUND_SERVICE_MICROPHONE !in granted) needs += "android.permission.FOREGROUND_SERVICE_MICROPHONE declared"
|
|
93
|
+
}
|
|
94
|
+
if (wantsPlayback && Manifest.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK !in granted) {
|
|
95
|
+
needs += "android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK declared"
|
|
96
|
+
}
|
|
97
|
+
return "the call service can run as neither microphone nor mediaPlayback; it needs " + needs.joinToString(" or ")
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Every ServiceInfo.FOREGROUND_SERVICE_TYPE_* by the name the manifest gives it, lowest bit first. */
|
|
101
|
+
private val NAMES = listOf(
|
|
102
|
+
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC to "dataSync",
|
|
103
|
+
MEDIA_PLAYBACK to "mediaPlayback",
|
|
104
|
+
ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL to "phoneCall",
|
|
105
|
+
ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION to "location",
|
|
106
|
+
ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE to "connectedDevice",
|
|
107
|
+
MEDIA_PROJECTION to "mediaProjection",
|
|
108
|
+
CAMERA to "camera",
|
|
109
|
+
MICROPHONE to "microphone",
|
|
110
|
+
ServiceInfo.FOREGROUND_SERVICE_TYPE_HEALTH to "health",
|
|
111
|
+
ServiceInfo.FOREGROUND_SERVICE_TYPE_REMOTE_MESSAGING to "remoteMessaging",
|
|
112
|
+
ServiceInfo.FOREGROUND_SERVICE_TYPE_SYSTEM_EXEMPTED to "systemExempted",
|
|
113
|
+
ServiceInfo.FOREGROUND_SERVICE_TYPE_SHORT_SERVICE to "shortService",
|
|
114
|
+
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROCESSING to "mediaProcessing",
|
|
115
|
+
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE to "specialUse",
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
/** The manifest's names for the types in [mask]. */
|
|
119
|
+
fun names(mask: Int): List<String> = NAMES.filter { (type, _) -> mask and type == type && type != 0 }.map { it.second }
|
|
120
|
+
}
|