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
}