mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-09-08 13:31:03 +03:00
fix: android: target API 36 (#15603)
* fix: android: target API 35 Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com> * fix: android: handle API 35 foreground service types Integrate the foreground-service and MediaProjection lifecycle changes from fufesou/rustdesk#68 while leaving storage permission handling to #15602. Co-authored-by: fufesou <linlong1266@gmail.com> Signed-off-by: fufesou <linlong1266@gmail.com> Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com> * fix: bump required android sdk version to 36, per recent google requirement change. Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com> * fix(android): clear microphone FGS type when capture stops Signed-off-by: fufesou <linlong1266@gmail.com> * fix(android): harden API 36 capture service lifecycle - isolate MediaProjection callbacks per session - keep foreground service types in sync with capture state - handle audio startup failures and shared frame ownership - upgrade AGP to 8.10.1 for API 36 support Signed-off-by: fufesou <linlong1266@gmail.com> * fix(android): reset capture state on FGS update failure Signed-off-by: fufesou <linlong1266@gmail.com> * fix(android): recover capture after projection failure Propagate virtual display startup failures, clean up partial video resources, and resume capture after media projection is reauthorized. Signed-off-by: fufesou <linlong1266@gmail.com> * fix(android): preserve voice call during projection replacement Keep the existing capture active until a new projection is acquired, and restore the voice-call audio source when capture restarts. Signed-off-by: fufesou <linlong1266@gmail.com> * fix(android): use JDK 17 in playground workflow Signed-off-by: fufesou <linlong1266@gmail.com> * fix(android): clear pending capture restart on denial Notify MainService when a recovery projection request is canceled so a later projection grant cannot restart stale capture state. Signed-off-by: fufesou <linlong1266@gmail.com> * fix(android): handle audio and projection recovery failures Verify AudioRecord startup, propagate voice-call restoration failures, and clear stale capture recovery state when projection setup fails. Signed-off-by: fufesou <linlong1266@gmail.com> --------- Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com> Signed-off-by: fufesou <linlong1266@gmail.com> Co-authored-by: fufesou <linlong1266@gmail.com>
This commit is contained in:
@@ -82,7 +82,8 @@ protobuf {
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdkVersion 34
|
||||
namespace "com.carriez.flutter_hbb"
|
||||
compileSdkVersion 36
|
||||
sourceSets {
|
||||
main.java.srcDirs += 'src/main/kotlin'
|
||||
|
||||
@@ -91,6 +92,7 @@ android {
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
coreLibraryDesugaringEnabled true
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
@@ -99,7 +101,7 @@ android {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
applicationId "com.carriez.flutter_hbb"
|
||||
minSdkVersion 22
|
||||
targetSdkVersion 33
|
||||
targetSdkVersion 36
|
||||
versionCode flutterVersionCode.toInteger()
|
||||
versionName flutterVersionName
|
||||
}
|
||||
@@ -128,6 +130,7 @@ flutter {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.4'
|
||||
implementation 'com.google.protobuf:protobuf-javalite:3.20.1'
|
||||
implementation "androidx.media:media:1.6.0"
|
||||
implementation 'com.github.getActivity:XXPermissions:18.5'
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
@@ -89,7 +91,12 @@
|
||||
<service
|
||||
android:name=".MainService"
|
||||
android:enabled="true"
|
||||
android:foregroundServiceType="mediaProjection" />
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse|mediaProjection|microphone">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="@string/foreground_service_special_use_subtype" />
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name=".FloatingWindowService"
|
||||
|
||||
@@ -18,7 +18,33 @@ const val AUDIO_SAMPLE_RATE = 48000
|
||||
const val AUDIO_CHANNEL_MASK = AudioFormat.CHANNEL_IN_STEREO
|
||||
|
||||
class AudioRecordHandle(private var context: Context, private var isVideoStart: ()->Boolean, private var isAudioStart: ()->Boolean) {
|
||||
private val logTag = "LOG_AUDIO_RECORD_HANDLE"
|
||||
companion object {
|
||||
private const val LOG_TAG = "LOG_AUDIO_RECORD_HANDLE"
|
||||
private const val NO_ACTIVE_PUBLISHERS = 0
|
||||
private var activeAudioFramePublishers = NO_ACTIVE_PUBLISHERS
|
||||
|
||||
@Synchronized
|
||||
private fun acquireAudioFramePublisher() {
|
||||
if (activeAudioFramePublishers == NO_ACTIVE_PUBLISHERS) {
|
||||
FFI.setFrameRawEnable("audio", true)
|
||||
}
|
||||
activeAudioFramePublishers++
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun releaseAudioFramePublisher() {
|
||||
if (activeAudioFramePublishers == NO_ACTIVE_PUBLISHERS) {
|
||||
Log.e(LOG_TAG, "No active audio frame publisher to release")
|
||||
return
|
||||
}
|
||||
activeAudioFramePublishers--
|
||||
if (activeAudioFramePublishers == NO_ACTIVE_PUBLISHERS) {
|
||||
FFI.setFrameRawEnable("audio", false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val logTag = LOG_TAG
|
||||
|
||||
private var audioRecorder: AudioRecord? = null
|
||||
private var audioReader: AudioReader? = null
|
||||
@@ -79,48 +105,94 @@ class AudioRecordHandle(private var context: Context, private var isVideoStart:
|
||||
return
|
||||
}
|
||||
// read f32 to byte , length * 4
|
||||
minBufferSize = 2 * 4 * AudioRecord.getMinBufferSize(
|
||||
val bufferSize = 2 * 4 * AudioRecord.getMinBufferSize(
|
||||
AUDIO_SAMPLE_RATE,
|
||||
AUDIO_CHANNEL_MASK,
|
||||
AUDIO_ENCODING
|
||||
)
|
||||
if (minBufferSize == 0) {
|
||||
if (bufferSize <= 0) {
|
||||
Log.d(logTag, "get min buffer size fail!")
|
||||
return
|
||||
}
|
||||
audioReader = AudioReader(minBufferSize, 4)
|
||||
audioReader = AudioReader(bufferSize, 4)
|
||||
minBufferSize = bufferSize
|
||||
Log.d(logTag, "init audioData len:$minBufferSize")
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
fun startAudioRecorder() {
|
||||
checkAudioReader()
|
||||
if (audioReader != null && audioRecorder != null && minBufferSize != 0) {
|
||||
try {
|
||||
FFI.setFrameRawEnable("audio", true)
|
||||
audioRecorder!!.startRecording()
|
||||
audioRecordStat = true
|
||||
audioThread = thread {
|
||||
while (audioRecordStat) {
|
||||
audioReader!!.readSync(audioRecorder!!)?.let {
|
||||
FFI.onAudioFrameUpdate(it)
|
||||
}
|
||||
}
|
||||
// let's release here rather than onDestroy to avoid threading issue
|
||||
audioRecorder?.release()
|
||||
audioRecorder = null
|
||||
minBufferSize = 0
|
||||
FFI.setFrameRawEnable("audio", false)
|
||||
Log.d(logTag, "Exit audio thread")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.d(logTag, "startAudioRecorder fail:$e")
|
||||
private fun releaseRecorder(recorder: AudioRecord) {
|
||||
try {
|
||||
recorder.release()
|
||||
} finally {
|
||||
if (audioRecorder === recorder) {
|
||||
audioRecorder = null
|
||||
}
|
||||
} else {
|
||||
Log.d(logTag, "startAudioRecorder fail")
|
||||
}
|
||||
}
|
||||
|
||||
private fun captureAudio(reader: AudioReader, recorder: AudioRecord) {
|
||||
try {
|
||||
while (audioRecordStat) {
|
||||
reader.readSync(recorder)?.let {
|
||||
FFI.onAudioFrameUpdate(it)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
minBufferSize = 0
|
||||
try {
|
||||
releaseRecorder(recorder)
|
||||
} finally {
|
||||
releaseAudioFramePublisher()
|
||||
Log.d(logTag, "Exit audio thread")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
fun startAudioRecorder(): Boolean {
|
||||
val recorder = audioRecorder
|
||||
if (recorder == null) {
|
||||
Log.d(logTag, "startAudioRecorder fail")
|
||||
return false
|
||||
}
|
||||
var audioFramePublisherAcquired = false
|
||||
return try {
|
||||
checkAudioReader()
|
||||
val reader = audioReader
|
||||
if (reader == null || minBufferSize == 0) {
|
||||
releaseRecorder(recorder)
|
||||
Log.d(logTag, "startAudioRecorder fail")
|
||||
return false
|
||||
}
|
||||
recorder.startRecording()
|
||||
if (recorder.recordingState != AudioRecord.RECORDSTATE_RECORDING) {
|
||||
throw IllegalStateException("AudioRecord failed to enter recording state")
|
||||
}
|
||||
audioRecordStat = true
|
||||
val captureThread = thread(start = false) { captureAudio(reader, recorder) }
|
||||
acquireAudioFramePublisher()
|
||||
audioFramePublisherAcquired = true
|
||||
audioThread = captureThread
|
||||
captureThread.start()
|
||||
true
|
||||
} catch (error: Exception) {
|
||||
audioRecordStat = false
|
||||
audioThread = null
|
||||
Log.e(logTag, "startAudioRecorder fail", error)
|
||||
try {
|
||||
releaseRecorder(recorder)
|
||||
} finally {
|
||||
if (audioFramePublisherAcquired) {
|
||||
releaseAudioFramePublisher()
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun isVoiceCallActive(): Boolean {
|
||||
return audioRecorder?.audioSource == MediaRecorder.AudioSource.VOICE_COMMUNICATION
|
||||
}
|
||||
|
||||
fun onVoiceCallStarted(mediaProjection: MediaProjection?): Boolean {
|
||||
if (!isSupportVoiceCall()) {
|
||||
return false
|
||||
@@ -137,11 +209,9 @@ class AudioRecordHandle(private var context: Context, private var isVideoStart:
|
||||
if (!isSupportVoiceCall()) {
|
||||
return true
|
||||
}
|
||||
if (isVideoStart()) {
|
||||
switchOutVoiceCall(mediaProjection)
|
||||
}
|
||||
val switched = !isVideoStart() || switchOutVoiceCall(mediaProjection)
|
||||
tryReleaseAudio()
|
||||
return true
|
||||
return switched
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
@@ -159,8 +229,7 @@ class AudioRecordHandle(private var context: Context, private var isVideoStart:
|
||||
Log.e(logTag, "createAudioRecorder fail")
|
||||
return false
|
||||
}
|
||||
startAudioRecorder()
|
||||
return true
|
||||
return startAudioRecorder()
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
@@ -177,8 +246,7 @@ class AudioRecordHandle(private var context: Context, private var isVideoStart:
|
||||
Log.e(logTag, "createAudioRecorder fail")
|
||||
return false
|
||||
}
|
||||
startAudioRecorder()
|
||||
return true
|
||||
return startAudioRecorder()
|
||||
}
|
||||
|
||||
fun tryReleaseAudio() {
|
||||
|
||||
@@ -17,6 +17,7 @@ import android.app.PendingIntent.FLAG_UPDATE_CURRENT
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.content.res.Configuration
|
||||
import android.content.res.Configuration.ORIENTATION_LANDSCAPE
|
||||
import android.graphics.Color
|
||||
@@ -150,7 +151,7 @@ class MainService : Service() {
|
||||
if (incomingVoiceCall) {
|
||||
voiceCallRequestNotification(id, "Voice Call Request", username, peerId)
|
||||
} else {
|
||||
if (!audioRecordHandle.switchOutVoiceCall(mediaProjection)) {
|
||||
if (!switchOutVoiceCall()) {
|
||||
Log.e(logTag, "switchOutVoiceCall fail")
|
||||
MainActivity.flutterMethodChannel?.invokeMethod("msgbox", mapOf(
|
||||
"type" to "custom-nook-nocancel-hasclose-error",
|
||||
@@ -159,7 +160,7 @@ class MainService : Service() {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (!audioRecordHandle.switchToVoiceCall(mediaProjection)) {
|
||||
if (!switchToVoiceCall()) {
|
||||
Log.e(logTag, "switchToVoiceCall fail")
|
||||
MainActivity.flutterMethodChannel?.invokeMethod("msgbox", mapOf(
|
||||
"type" to "custom-nook-nocancel-hasclose-error",
|
||||
@@ -214,17 +215,19 @@ class MainService : Service() {
|
||||
|
||||
// video
|
||||
private var mediaProjection: MediaProjection? = null
|
||||
private val mediaProjectionCallback = object : MediaProjection.Callback() {
|
||||
override fun onStop() {
|
||||
Log.d(logTag, "MediaProjection stopped")
|
||||
stopCapture()
|
||||
virtualDisplay?.release()
|
||||
virtualDisplay = null
|
||||
releaseMediaProjection()
|
||||
_isReady = false
|
||||
checkMediaPermission()
|
||||
private var mediaProjectionCallback: MediaProjection.Callback? = null
|
||||
private var captureRestartPending = false
|
||||
private var captureRestartInVoiceCall = false
|
||||
private val mediaProjectionResultReceiver =
|
||||
object : ResultReceiver(Handler(Looper.getMainLooper())) {
|
||||
override fun onReceiveResult(resultCode: Int, resultData: Bundle?) {
|
||||
if (resultCode == RES_FAILED) {
|
||||
cancelMediaProjectionRecovery()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
private var mediaProjectionForegroundService = false
|
||||
private var microphoneForegroundService = false
|
||||
private var surface: Surface? = null
|
||||
private val sendVP9Thread = Executors.newSingleThreadExecutor()
|
||||
private var videoEncoder: MediaCodec? = null
|
||||
@@ -350,8 +353,6 @@ class MainService : Service() {
|
||||
Log.d("whichService", "this service: ${Thread.currentThread()}")
|
||||
super.onStartCommand(intent, flags, startId)
|
||||
if (intent?.action == ACT_INIT_MEDIA_PROJECTION_AND_SERVICE) {
|
||||
createForegroundNotification()
|
||||
|
||||
if (intent.getBooleanExtra(EXT_INIT_FROM_BOOT, false)) {
|
||||
FFI.startService()
|
||||
}
|
||||
@@ -360,13 +361,7 @@ class MainService : Service() {
|
||||
getSystemService(MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
|
||||
|
||||
intent.getParcelableExtra<Intent>(EXT_MEDIA_PROJECTION_RES_INTENT)?.let {
|
||||
releaseMediaProjection()
|
||||
val projection =
|
||||
mediaProjectionManager.getMediaProjection(Activity.RESULT_OK, it)
|
||||
projection.registerCallback(mediaProjectionCallback, Handler(Looper.getMainLooper()))
|
||||
mediaProjection = projection
|
||||
_isReady = true
|
||||
checkMediaPermission()
|
||||
replaceMediaProjection(mediaProjectionManager, it)
|
||||
} ?: let {
|
||||
Log.d(logTag, "getParcelableExtra intent null, invoke requestMediaProjection")
|
||||
requestMediaProjection()
|
||||
@@ -380,18 +375,21 @@ class MainService : Service() {
|
||||
updateScreenInfo(newConfig.orientation)
|
||||
}
|
||||
|
||||
private fun requestMediaProjection() {
|
||||
private fun requestMediaProjection(recovery: Boolean = false) {
|
||||
val intent = Intent(this, PermissionRequestTransparentActivity::class.java).apply {
|
||||
action = ACT_REQUEST_MEDIA_PROJECTION
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
if (recovery) {
|
||||
putExtra(EXT_MEDIA_PROJECTION_RESULT_RECEIVER, mediaProjectionResultReceiver)
|
||||
}
|
||||
}
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
private fun releaseMediaProjection() {
|
||||
mediaProjection?.unregisterCallback(mediaProjectionCallback)
|
||||
mediaProjection?.stop()
|
||||
mediaProjection = null
|
||||
@Synchronized
|
||||
private fun cancelMediaProjectionRecovery() {
|
||||
captureRestartPending = false
|
||||
captureRestartInVoiceCall = false
|
||||
}
|
||||
|
||||
@SuppressLint("WrongConstant")
|
||||
@@ -427,15 +425,149 @@ class MainService : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
fun onVoiceCallStarted(): Boolean {
|
||||
return audioRecordHandle.onVoiceCallStarted(mediaProjection)
|
||||
private fun releaseMediaProjection() {
|
||||
val projection = mediaProjection
|
||||
val callback = mediaProjectionCallback
|
||||
mediaProjection = null
|
||||
mediaProjectionCallback = null
|
||||
if (projection != null && callback != null) {
|
||||
projection.unregisterCallback(callback)
|
||||
}
|
||||
projection?.stop()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun handleMediaProjectionStopped(stoppedProjection: MediaProjection) {
|
||||
if (mediaProjection !== stoppedProjection) {
|
||||
return
|
||||
}
|
||||
Log.d(logTag, "MediaProjection stopped")
|
||||
setMediaProjectionForegroundService(false)
|
||||
stopCapture()
|
||||
virtualDisplay?.release()
|
||||
virtualDisplay = null
|
||||
mediaProjection = null
|
||||
mediaProjectionCallback = null
|
||||
_isReady = false
|
||||
checkMediaPermission()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun replaceMediaProjection(
|
||||
mediaProjectionManager: MediaProjectionManager,
|
||||
resultIntent: Intent,
|
||||
) {
|
||||
val wasCapturing = isStart
|
||||
val restartCapture = wasCapturing || captureRestartPending
|
||||
val restartInVoiceCall = if (wasCapturing) {
|
||||
audioRecordHandle.isVoiceCallActive()
|
||||
} else {
|
||||
captureRestartInVoiceCall
|
||||
}
|
||||
val hadProjection = mediaProjection != null
|
||||
if (!setMediaProjectionForegroundService(true)) {
|
||||
if (!hadProjection) {
|
||||
cancelMediaProjectionRecovery()
|
||||
_isReady = false
|
||||
checkMediaPermission()
|
||||
}
|
||||
return
|
||||
}
|
||||
val projection =
|
||||
mediaProjectionManager.getMediaProjection(Activity.RESULT_OK, resultIntent)
|
||||
if (projection == null) {
|
||||
if (!hadProjection) {
|
||||
cancelMediaProjectionRecovery()
|
||||
_isReady = false
|
||||
setMediaProjectionForegroundService(false)
|
||||
checkMediaPermission()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (wasCapturing) {
|
||||
stopCapture()
|
||||
}
|
||||
captureRestartPending = restartCapture
|
||||
virtualDisplay?.release()
|
||||
virtualDisplay = null
|
||||
releaseMediaProjection()
|
||||
val callback = object : MediaProjection.Callback() {
|
||||
override fun onStop() {
|
||||
handleMediaProjectionStopped(projection)
|
||||
}
|
||||
}
|
||||
projection.registerCallback(callback, Handler(Looper.getMainLooper()))
|
||||
mediaProjection = projection
|
||||
mediaProjectionCallback = callback
|
||||
_isReady = true
|
||||
checkMediaPermission()
|
||||
if (restartCapture) {
|
||||
captureRestartPending = false
|
||||
startCapture(restartInVoiceCall)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun startMicrophoneCapture(startAudio: () -> Boolean): Boolean {
|
||||
if (!setMicrophoneForegroundService(true)) {
|
||||
return false
|
||||
}
|
||||
if (startAudio()) {
|
||||
return true
|
||||
}
|
||||
setMicrophoneForegroundService(false)
|
||||
return false
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun stopMicrophoneCapture(stopAudio: () -> Boolean): Boolean {
|
||||
val stopped = stopAudio()
|
||||
val foregroundServiceUpdated = setMicrophoneForegroundService(false)
|
||||
return stopped && foregroundServiceUpdated
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun switchToVoiceCall(): Boolean {
|
||||
if (captureRestartPending) {
|
||||
captureRestartInVoiceCall = true
|
||||
}
|
||||
return startMicrophoneCapture {
|
||||
audioRecordHandle.switchToVoiceCall(mediaProjection)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun switchOutVoiceCall(): Boolean {
|
||||
captureRestartInVoiceCall = false
|
||||
val switched = audioRecordHandle.switchOutVoiceCall(mediaProjection)
|
||||
val foregroundServiceUpdated = setMicrophoneForegroundService(false)
|
||||
return switched && foregroundServiceUpdated
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun onVoiceCallStarted(): Boolean {
|
||||
if (captureRestartPending) {
|
||||
captureRestartInVoiceCall = true
|
||||
}
|
||||
return startMicrophoneCapture {
|
||||
audioRecordHandle.onVoiceCallStarted(mediaProjection)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun onVoiceCallClosed(): Boolean {
|
||||
return audioRecordHandle.onVoiceCallClosed(mediaProjection)
|
||||
captureRestartInVoiceCall = false
|
||||
return stopMicrophoneCapture {
|
||||
audioRecordHandle.onVoiceCallClosed(mediaProjection)
|
||||
}
|
||||
}
|
||||
|
||||
fun startCapture(): Boolean {
|
||||
return startCapture(false)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun startCapture(inVoiceCall: Boolean): Boolean {
|
||||
if (isStart) {
|
||||
return true
|
||||
}
|
||||
@@ -443,25 +575,35 @@ class MainService : Service() {
|
||||
Log.w(logTag, "startCapture fail,mediaProjection is null")
|
||||
return false
|
||||
}
|
||||
captureRestartInVoiceCall = inVoiceCall
|
||||
|
||||
updateScreenInfo(resources.configuration.orientation)
|
||||
Log.d(logTag, "Start Capture")
|
||||
surface = createSurface()
|
||||
|
||||
if (useVP9) {
|
||||
val videoStarted = if (useVP9) {
|
||||
startVP9VideoRecorder(mediaProjection!!)
|
||||
} else {
|
||||
startRawVideoRecorder(mediaProjection!!)
|
||||
}
|
||||
if (!videoStarted) {
|
||||
if (!captureRestartPending) {
|
||||
captureRestartInVoiceCall = false
|
||||
}
|
||||
releaseFailedVideoCapture()
|
||||
return false
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
if (!audioRecordHandle.createAudioRecorder(false, mediaProjection)) {
|
||||
Log.d(logTag, "createAudioRecorder fail")
|
||||
val audioStarted = if (inVoiceCall) {
|
||||
switchToVoiceCall()
|
||||
} else {
|
||||
Log.d(logTag, "audio recorder start")
|
||||
audioRecordHandle.startAudioRecorder()
|
||||
audioRecordHandle.createAudioRecorder(false, mediaProjection) &&
|
||||
audioRecordHandle.startAudioRecorder()
|
||||
}
|
||||
Log.d(logTag, if (audioStarted) "audio recorder start" else "audio recorder start failed")
|
||||
}
|
||||
captureRestartInVoiceCall = false
|
||||
checkMediaPermission()
|
||||
_isStart = true
|
||||
FFI.setFrameRawEnable("video",true)
|
||||
@@ -469,9 +611,24 @@ class MainService : Service() {
|
||||
return true
|
||||
}
|
||||
|
||||
private fun releaseFailedVideoCapture() {
|
||||
imageReader?.close()
|
||||
imageReader = null
|
||||
videoEncoder?.let {
|
||||
it.signalEndOfInputStream()
|
||||
it.stop()
|
||||
it.release()
|
||||
}
|
||||
videoEncoder = null
|
||||
surface?.release()
|
||||
surface = null
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun stopCapture() {
|
||||
Log.d(logTag, "Stop Capture")
|
||||
captureRestartPending = false
|
||||
captureRestartInVoiceCall = false
|
||||
FFI.setFrameRawEnable("video",false)
|
||||
_isStart = false
|
||||
MainActivity.rdClipboardManager?.setCaptureStarted(_isStart)
|
||||
@@ -502,8 +659,11 @@ class MainService : Service() {
|
||||
surface?.release()
|
||||
|
||||
// release audio
|
||||
_isAudioStart = false
|
||||
audioRecordHandle.tryReleaseAudio()
|
||||
stopMicrophoneCapture {
|
||||
_isAudioStart = false
|
||||
audioRecordHandle.tryReleaseAudio()
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
@@ -519,6 +679,8 @@ class MainService : Service() {
|
||||
}
|
||||
|
||||
releaseMediaProjection()
|
||||
mediaProjectionForegroundService = false
|
||||
microphoneForegroundService = false
|
||||
checkMediaPermission()
|
||||
stopForeground(true)
|
||||
stopService(Intent(this, FloatingWindowService::class.java))
|
||||
@@ -541,49 +703,70 @@ class MainService : Service() {
|
||||
return isReady
|
||||
}
|
||||
|
||||
private fun startRawVideoRecorder(mp: MediaProjection) {
|
||||
private fun startRawVideoRecorder(mp: MediaProjection): Boolean {
|
||||
Log.d(logTag, "startRawVideoRecorder,screen info:$SCREEN_INFO")
|
||||
if (surface == null) {
|
||||
val captureSurface = surface
|
||||
if (captureSurface == null) {
|
||||
Log.d(logTag, "startRawVideoRecorder failed,surface is null")
|
||||
return
|
||||
return false
|
||||
}
|
||||
createOrSetVirtualDisplay(mp, surface!!)
|
||||
return createOrSetVirtualDisplay(mp, captureSurface)
|
||||
}
|
||||
|
||||
private fun startVP9VideoRecorder(mp: MediaProjection) {
|
||||
private fun startVP9VideoRecorder(mp: MediaProjection): Boolean {
|
||||
createMediaCodec()
|
||||
videoEncoder?.let {
|
||||
surface = it.createInputSurface()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
surface!!.setFrameRate(1F, FRAME_RATE_COMPATIBILITY_DEFAULT)
|
||||
}
|
||||
it.setCallback(cb)
|
||||
it.start()
|
||||
createOrSetVirtualDisplay(mp, surface!!)
|
||||
val encoder = videoEncoder ?: return false
|
||||
val inputSurface = encoder.createInputSurface()
|
||||
surface = inputSurface
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
inputSurface.setFrameRate(1F, FRAME_RATE_COMPATIBILITY_DEFAULT)
|
||||
}
|
||||
encoder.setCallback(cb)
|
||||
encoder.start()
|
||||
return createOrSetVirtualDisplay(mp, inputSurface)
|
||||
}
|
||||
|
||||
// https://github.com/bk138/droidVNC-NG/blob/b79af62db5a1c08ed94e6a91464859ffed6f4e97/app/src/main/java/net/christianbeier/droidvnc_ng/MediaProjectionService.java#L250
|
||||
// Reuse virtualDisplay if it exists, to avoid media projection confirmation dialog every connection.
|
||||
private fun createOrSetVirtualDisplay(mp: MediaProjection, s: Surface) {
|
||||
try {
|
||||
virtualDisplay?.let {
|
||||
it.resize(SCREEN_INFO.width, SCREEN_INFO.height, SCREEN_INFO.dpi)
|
||||
it.setSurface(s)
|
||||
} ?: let {
|
||||
virtualDisplay = mp.createVirtualDisplay(
|
||||
private fun createOrSetVirtualDisplay(mp: MediaProjection, s: Surface): Boolean {
|
||||
return try {
|
||||
val existingDisplay = virtualDisplay
|
||||
if (existingDisplay != null) {
|
||||
existingDisplay.resize(SCREEN_INFO.width, SCREEN_INFO.height, SCREEN_INFO.dpi)
|
||||
existingDisplay.setSurface(s)
|
||||
true
|
||||
} else {
|
||||
val display = mp.createVirtualDisplay(
|
||||
"RustDeskVD",
|
||||
SCREEN_INFO.width, SCREEN_INFO.height, SCREEN_INFO.dpi, VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
|
||||
s, null, null
|
||||
)
|
||||
if (display == null) {
|
||||
Log.e(logTag, "createOrSetVirtualDisplay failed")
|
||||
handleVirtualDisplayFailure()
|
||||
} else {
|
||||
virtualDisplay = display
|
||||
true
|
||||
}
|
||||
}
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(logTag, "createOrSetVirtualDisplay: got SecurityException, re-requesting confirmation");
|
||||
// This initiates a prompt dialog for the user to confirm screen projection.
|
||||
requestMediaProjection()
|
||||
Log.w(logTag, "createOrSetVirtualDisplay: got SecurityException", e)
|
||||
handleVirtualDisplayFailure()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleVirtualDisplayFailure(): Boolean {
|
||||
captureRestartPending = true
|
||||
virtualDisplay?.release()
|
||||
virtualDisplay = null
|
||||
releaseMediaProjection()
|
||||
setMediaProjectionForegroundService(false)
|
||||
_isReady = false
|
||||
checkMediaPermission()
|
||||
requestMediaProjection(true)
|
||||
return false
|
||||
}
|
||||
|
||||
private val cb: MediaCodec.Callback = object : MediaCodec.Callback() {
|
||||
override fun onInputBufferAvailable(codec: MediaCodec, index: Int) {}
|
||||
override fun onOutputFormatChanged(codec: MediaCodec, format: MediaFormat) {}
|
||||
@@ -674,7 +857,63 @@ class MainService : Service() {
|
||||
.setColor(ContextCompat.getColor(this, R.color.primary))
|
||||
.setWhen(System.currentTimeMillis())
|
||||
.build()
|
||||
startForeground(DEFAULT_NOTIFY_ID, notification)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
startForeground(DEFAULT_NOTIFY_ID, notification, foregroundServiceType())
|
||||
} else {
|
||||
startForeground(DEFAULT_NOTIFY_ID, notification)
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
private fun foregroundServiceType(): Int {
|
||||
var serviceType = ServiceInfo.FOREGROUND_SERVICE_TYPE_NONE
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
// Keep a valid FGS type while the unattended host is idle and no capture type is active.
|
||||
serviceType = serviceType or ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
|
||||
}
|
||||
if (mediaProjectionForegroundService) {
|
||||
serviceType = serviceType or ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && microphoneForegroundService) {
|
||||
serviceType = serviceType or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
|
||||
}
|
||||
return serviceType
|
||||
}
|
||||
|
||||
private fun setMediaProjectionForegroundService(enabled: Boolean): Boolean {
|
||||
return updateForegroundServiceTypes(enabled, microphoneForegroundService)
|
||||
}
|
||||
|
||||
private fun setMicrophoneForegroundService(enabled: Boolean): Boolean {
|
||||
return updateForegroundServiceTypes(mediaProjectionForegroundService, enabled)
|
||||
}
|
||||
|
||||
private fun updateForegroundServiceTypes(
|
||||
mediaProjectionEnabled: Boolean,
|
||||
microphoneEnabled: Boolean,
|
||||
): Boolean {
|
||||
if (mediaProjectionForegroundService == mediaProjectionEnabled &&
|
||||
microphoneForegroundService == microphoneEnabled) {
|
||||
return true
|
||||
}
|
||||
val previousMediaProjection = mediaProjectionForegroundService
|
||||
val previousMicrophone = microphoneForegroundService
|
||||
mediaProjectionForegroundService = mediaProjectionEnabled
|
||||
microphoneForegroundService = microphoneEnabled
|
||||
return try {
|
||||
createForegroundNotification()
|
||||
true
|
||||
} catch (error: SecurityException) {
|
||||
mediaProjectionForegroundService = previousMediaProjection
|
||||
microphoneForegroundService = previousMicrophone
|
||||
Log.e(logTag, "Failed to update foreground service types", error)
|
||||
false
|
||||
} catch (error: IllegalStateException) {
|
||||
mediaProjectionForegroundService = previousMediaProjection
|
||||
microphoneForegroundService = previousMicrophone
|
||||
Log.e(logTag, "Failed to update foreground service types", error)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun loginRequestNotification(
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.content.Intent
|
||||
import android.media.projection.MediaProjectionManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.ResultReceiver
|
||||
import android.util.Log
|
||||
|
||||
class PermissionRequestTransparentActivity: Activity() {
|
||||
@@ -31,7 +32,13 @@ class PermissionRequestTransparentActivity: Activity() {
|
||||
if (resultCode == RESULT_OK && data != null) {
|
||||
launchService(data)
|
||||
} else {
|
||||
setResult(RES_FAILED)
|
||||
val resultReceiver =
|
||||
intent.getParcelableExtra<ResultReceiver>(EXT_MEDIA_PROJECTION_RESULT_RECEIVER)
|
||||
if (resultReceiver != null) {
|
||||
resultReceiver.send(RES_FAILED, null)
|
||||
} else {
|
||||
setResult(RES_FAILED)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,4 +58,4 @@ class PermissionRequestTransparentActivity: Activity() {
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ const val ACT_INIT_MEDIA_PROJECTION_AND_SERVICE = "INIT_MEDIA_PROJECTION_AND_SER
|
||||
const val ACT_LOGIN_REQ_NOTIFY = "LOGIN_REQ_NOTIFY"
|
||||
const val EXT_INIT_FROM_BOOT = "EXT_INIT_FROM_BOOT"
|
||||
const val EXT_MEDIA_PROJECTION_RES_INTENT = "MEDIA_PROJECTION_RES_INTENT"
|
||||
const val EXT_MEDIA_PROJECTION_RESULT_RECEIVER = "MEDIA_PROJECTION_RESULT_RECEIVER"
|
||||
const val EXT_LOGIN_REQ_NOTIFY = "LOGIN_REQ_NOTIFY"
|
||||
|
||||
// Activity requestCode
|
||||
@@ -164,4 +165,4 @@ fun getScreenSize(windowManager: WindowManager) : Pair<Int, Int>{
|
||||
fun translate(input: String): String {
|
||||
Log.d("common", "translate:$LOCAL_NAME")
|
||||
return FFI.translateLocale(LOCAL_NAME, input)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<resources>
|
||||
<string name="app_name">RustDesk</string>
|
||||
<string name="accessibility_service_description">Allow other devices to control your phone using virtual touch, when RustDesk screen sharing is established</string>
|
||||
<string name="foreground_service_special_use_subtype">Keeps the RustDesk remote desktop host available for authorized unattended connections and foreground notifications without starting screen capture before user approval.</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,3 +1,29 @@
|
||||
def legacyPluginNamespaces = [
|
||||
external_path: 'com.pinciat.external_path',
|
||||
flutter_keyboard_visibility: 'com.jrai.flutter_keyboard_visibility',
|
||||
qr_code_scanner: 'net.touchcapture.qr.flutterqr',
|
||||
sqflite: 'com.tekartik.sqflite',
|
||||
uni_links: 'name.avioli.unilinks',
|
||||
]
|
||||
|
||||
def java8JvmTarget = JavaVersion.VERSION_1_8.toString()
|
||||
def java8KotlinJvmTargets = [
|
||||
app: java8JvmTarget,
|
||||
external_path: java8JvmTarget,
|
||||
qr_code_scanner: java8JvmTarget,
|
||||
]
|
||||
|
||||
def configureKotlinJvmTarget = { Project project, String kotlinJvmTarget ->
|
||||
project.plugins.withId('kotlin-android') {
|
||||
project.tasks.configureEach { task ->
|
||||
if (!task.hasProperty('kotlinOptions')) {
|
||||
return
|
||||
}
|
||||
task.kotlinOptions.jvmTarget = kotlinJvmTarget
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
@@ -9,6 +35,16 @@ allprojects {
|
||||
rootProject.buildDir = '../build'
|
||||
subprojects {
|
||||
project.buildDir = "${rootProject.buildDir}/${project.name}"
|
||||
def legacyNamespace = legacyPluginNamespaces[project.name]
|
||||
if (legacyNamespace != null) {
|
||||
project.plugins.withId('com.android.library') {
|
||||
project.android.namespace = legacyNamespace
|
||||
}
|
||||
}
|
||||
def kotlinJvmTarget = java8KotlinJvmTargets[project.name]
|
||||
if (kotlinJvmTarget != null) {
|
||||
configureKotlinJvmTarget(project, kotlinJvmTarget)
|
||||
}
|
||||
}
|
||||
subprojects {
|
||||
project.evaluationDependsOn(':app')
|
||||
|
||||
@@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.4-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip
|
||||
|
||||
@@ -18,7 +18,7 @@ pluginManagement {
|
||||
|
||||
plugins {
|
||||
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
|
||||
id "com.android.application" version "7.3.1" apply false
|
||||
id "com.android.application" version "8.10.1" apply false
|
||||
id "org.jetbrains.kotlin.android" version "2.1.21" apply false
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user