From 1ec1b9e7e3808b24bfc923ee1e6800ab9694e9a5 Mon Sep 17 00:00:00 2001 From: Michael Clark <104532890+michaeljclarkk@users.noreply.github.com> Date: Tue, 1 Sep 2026 00:29:51 +1000 Subject: [PATCH] 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 Signed-off-by: fufesou 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 * 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 * fix(android): reset capture state on FGS update failure Signed-off-by: fufesou * 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 * 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 * fix(android): use JDK 17 in playground workflow Signed-off-by: fufesou * 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 * 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 --------- Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com> Signed-off-by: fufesou Co-authored-by: fufesou --- .github/workflows/playground.yml | 6 +- flutter/android/app/build.gradle | 7 +- .../android/app/src/main/AndroidManifest.xml | 9 +- .../carriez/flutter_hbb/AudioRecordHandle.kt | 136 +++++-- .../com/carriez/flutter_hbb/MainService.kt | 357 +++++++++++++++--- .../PermissionRequestTransparentActivity.kt | 11 +- .../kotlin/com/carriez/flutter_hbb/common.kt | 3 +- .../app/src/main/res/values/strings.xml | 1 + flutter/android/build.gradle | 36 ++ .../gradle/wrapper/gradle-wrapper.properties | 2 +- flutter/android/settings.gradle | 2 +- 11 files changed, 466 insertions(+), 104 deletions(-) diff --git a/.github/workflows/playground.yml b/.github/workflows/playground.yml index f8e408f761d..7dee9b83a75 100644 --- a/.github/workflows/playground.yml +++ b/.github/workflows/playground.yml @@ -283,7 +283,7 @@ jobs: nasm \ yasm \ ninja-build \ - openjdk-11-jdk-headless \ + openjdk-17-jdk-headless \ pkg-config \ tree \ wget @@ -365,9 +365,9 @@ jobs: - name: Build rustdesk shell: bash env: - JAVA_HOME: /usr/lib/jvm/java-11-openjdk-amd64 + JAVA_HOME: /usr/lib/jvm/java-17-openjdk-amd64 run: | - export PATH=/usr/lib/jvm/java-11-openjdk-amd64/bin:$PATH + export PATH=/usr/lib/jvm/java-17-openjdk-amd64/bin:$PATH # temporary use debug sign config sed -i "s/signingConfigs.release/signingConfigs.debug/g" ./flutter/android/app/build.gradle case ${{ matrix.job.target }} in diff --git a/flutter/android/app/build.gradle b/flutter/android/app/build.gradle index 830cbc2ddc1..44eb32ca0a1 100644 --- a/flutter/android/app/build.gradle +++ b/flutter/android/app/build.gradle @@ -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' diff --git a/flutter/android/app/src/main/AndroidManifest.xml b/flutter/android/app/src/main/AndroidManifest.xml index 2d9616a6cac..e0788184694 100644 --- a/flutter/android/app/src/main/AndroidManifest.xml +++ b/flutter/android/app/src/main/AndroidManifest.xml @@ -12,6 +12,8 @@ + + @@ -89,7 +91,12 @@ + android:exported="false" + android:foregroundServiceType="specialUse|mediaProjection|microphone"> + + 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") } + private fun releaseRecorder(recorder: AudioRecord) { + try { + recorder.release() + } finally { + if (audioRecorder === recorder) { + audioRecorder = null + } + } + } + + 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() { - checkAudioReader() - if (audioReader != null && audioRecorder != null && minBufferSize != 0) { + 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 { - 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") + releaseRecorder(recorder) + } finally { + if (audioFramePublisherAcquired) { + releaseAudioFramePublisher() } - } catch (e: Exception) { - Log.d(logTag, "startAudioRecorder fail:$e") } - } else { - Log.d(logTag, "startAudioRecorder fail") + 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() { diff --git a/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainService.kt b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainService.kt index 4648b9adce1..cfee6ab47c6 100644 --- a/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainService.kt +++ b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainService.kt @@ -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(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() { } } + 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 { - return audioRecordHandle.onVoiceCallStarted(mediaProjection) + 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( diff --git a/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/PermissionRequestTransparentActivity.kt b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/PermissionRequestTransparentActivity.kt index 3beb7ec6b99..9034d2096de 100644 --- a/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/PermissionRequestTransparentActivity.kt +++ b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/PermissionRequestTransparentActivity.kt @@ -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(EXT_MEDIA_PROJECTION_RESULT_RECEIVER) + if (resultReceiver != null) { + resultReceiver.send(RES_FAILED, null) + } else { + setResult(RES_FAILED) + } } } @@ -51,4 +58,4 @@ class PermissionRequestTransparentActivity: Activity() { } } -} \ No newline at end of file +} diff --git a/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/common.kt b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/common.kt index 2923cad9f26..b59dca945b0 100644 --- a/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/common.kt +++ b/flutter/android/app/src/main/kotlin/com/carriez/flutter_hbb/common.kt @@ -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{ fun translate(input: String): String { Log.d("common", "translate:$LOCAL_NAME") return FFI.translateLocale(LOCAL_NAME, input) -} \ No newline at end of file +} diff --git a/flutter/android/app/src/main/res/values/strings.xml b/flutter/android/app/src/main/res/values/strings.xml index 3e058a81b81..eae55d590ab 100644 --- a/flutter/android/app/src/main/res/values/strings.xml +++ b/flutter/android/app/src/main/res/values/strings.xml @@ -1,4 +1,5 @@ RustDesk Allow other devices to control your phone using virtual touch, when RustDesk screen sharing is established + Keeps the RustDesk remote desktop host available for authorized unattended connections and foreground notifications without starting screen capture before user approval. diff --git a/flutter/android/build.gradle b/flutter/android/build.gradle index 401bea0096e..5733740c202 100644 --- a/flutter/android/build.gradle +++ b/flutter/android/build.gradle @@ -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') diff --git a/flutter/android/gradle/wrapper/gradle-wrapper.properties b/flutter/android/gradle/wrapper/gradle-wrapper.properties index cb576305fbe..9162f1008ac 100644 --- a/flutter/android/gradle/wrapper/gradle-wrapper.properties +++ b/flutter/android/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/flutter/android/settings.gradle b/flutter/android/settings.gradle index ae32fa00e5d..b72bea584ff 100644 --- a/flutter/android/settings.gradle +++ b/flutter/android/settings.gradle @@ -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 }