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:
Michael Clark
2026-09-01 00:29:51 +10:00
committed by GitHub
parent 2c84c8fb13
commit 1ec1b9e7e3
11 changed files with 470 additions and 108 deletions

View File

@@ -283,7 +283,7 @@ jobs:
nasm \ nasm \
yasm \ yasm \
ninja-build \ ninja-build \
openjdk-11-jdk-headless \ openjdk-17-jdk-headless \
pkg-config \ pkg-config \
tree \ tree \
wget wget
@@ -365,9 +365,9 @@ jobs:
- name: Build rustdesk - name: Build rustdesk
shell: bash shell: bash
env: env:
JAVA_HOME: /usr/lib/jvm/java-11-openjdk-amd64 JAVA_HOME: /usr/lib/jvm/java-17-openjdk-amd64
run: | 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 # temporary use debug sign config
sed -i "s/signingConfigs.release/signingConfigs.debug/g" ./flutter/android/app/build.gradle sed -i "s/signingConfigs.release/signingConfigs.debug/g" ./flutter/android/app/build.gradle
case ${{ matrix.job.target }} in case ${{ matrix.job.target }} in

View File

@@ -82,7 +82,8 @@ protobuf {
} }
android { android {
compileSdkVersion 34 namespace "com.carriez.flutter_hbb"
compileSdkVersion 36
sourceSets { sourceSets {
main.java.srcDirs += 'src/main/kotlin' main.java.srcDirs += 'src/main/kotlin'
@@ -91,6 +92,7 @@ android {
} }
compileOptions { compileOptions {
coreLibraryDesugaringEnabled true
targetCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8
sourceCompatibility 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). // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId "com.carriez.flutter_hbb" applicationId "com.carriez.flutter_hbb"
minSdkVersion 22 minSdkVersion 22
targetSdkVersion 33 targetSdkVersion 36
versionCode flutterVersionCode.toInteger() versionCode flutterVersionCode.toInteger()
versionName flutterVersionName versionName flutterVersionName
} }
@@ -128,6 +130,7 @@ flutter {
} }
dependencies { dependencies {
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.4'
implementation 'com.google.protobuf:protobuf-javalite:3.20.1' implementation 'com.google.protobuf:protobuf-javalite:3.20.1'
implementation "androidx.media:media:1.6.0" implementation "androidx.media:media:1.6.0"
implementation 'com.github.getActivity:XXPermissions:18.5' implementation 'com.github.getActivity:XXPermissions:18.5'

View File

@@ -12,6 +12,8 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <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" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" /> <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.RECORD_AUDIO" />
<uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
@@ -89,7 +91,12 @@
<service <service
android:name=".MainService" android:name=".MainService"
android:enabled="true" 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 <service
android:name=".FloatingWindowService" android:name=".FloatingWindowService"

View File

@@ -18,7 +18,33 @@ const val AUDIO_SAMPLE_RATE = 48000
const val AUDIO_CHANNEL_MASK = AudioFormat.CHANNEL_IN_STEREO const val AUDIO_CHANNEL_MASK = AudioFormat.CHANNEL_IN_STEREO
class AudioRecordHandle(private var context: Context, private var isVideoStart: ()->Boolean, private var isAudioStart: ()->Boolean) { 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 audioRecorder: AudioRecord? = null
private var audioReader: AudioReader? = null private var audioReader: AudioReader? = null
@@ -79,48 +105,94 @@ class AudioRecordHandle(private var context: Context, private var isVideoStart:
return return
} }
// read f32 to byte , length * 4 // read f32 to byte , length * 4
minBufferSize = 2 * 4 * AudioRecord.getMinBufferSize( val bufferSize = 2 * 4 * AudioRecord.getMinBufferSize(
AUDIO_SAMPLE_RATE, AUDIO_SAMPLE_RATE,
AUDIO_CHANNEL_MASK, AUDIO_CHANNEL_MASK,
AUDIO_ENCODING AUDIO_ENCODING
) )
if (minBufferSize == 0) { if (bufferSize <= 0) {
Log.d(logTag, "get min buffer size fail!") Log.d(logTag, "get min buffer size fail!")
return return
} }
audioReader = AudioReader(minBufferSize, 4) audioReader = AudioReader(bufferSize, 4)
minBufferSize = bufferSize
Log.d(logTag, "init audioData len:$minBufferSize") Log.d(logTag, "init audioData len:$minBufferSize")
} }
@RequiresApi(Build.VERSION_CODES.M) private fun releaseRecorder(recorder: AudioRecord) {
fun startAudioRecorder() { try {
checkAudioReader() recorder.release()
if (audioReader != null && audioRecorder != null && minBufferSize != 0) { } finally {
try { if (audioRecorder === recorder) {
FFI.setFrameRawEnable("audio", true) audioRecorder = null
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")
} }
} 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 { fun onVoiceCallStarted(mediaProjection: MediaProjection?): Boolean {
if (!isSupportVoiceCall()) { if (!isSupportVoiceCall()) {
return false return false
@@ -137,11 +209,9 @@ class AudioRecordHandle(private var context: Context, private var isVideoStart:
if (!isSupportVoiceCall()) { if (!isSupportVoiceCall()) {
return true return true
} }
if (isVideoStart()) { val switched = !isVideoStart() || switchOutVoiceCall(mediaProjection)
switchOutVoiceCall(mediaProjection)
}
tryReleaseAudio() tryReleaseAudio()
return true return switched
} }
@RequiresApi(Build.VERSION_CODES.M) @RequiresApi(Build.VERSION_CODES.M)
@@ -159,8 +229,7 @@ class AudioRecordHandle(private var context: Context, private var isVideoStart:
Log.e(logTag, "createAudioRecorder fail") Log.e(logTag, "createAudioRecorder fail")
return false return false
} }
startAudioRecorder() return startAudioRecorder()
return true
} }
@RequiresApi(Build.VERSION_CODES.M) @RequiresApi(Build.VERSION_CODES.M)
@@ -177,8 +246,7 @@ class AudioRecordHandle(private var context: Context, private var isVideoStart:
Log.e(logTag, "createAudioRecorder fail") Log.e(logTag, "createAudioRecorder fail")
return false return false
} }
startAudioRecorder() return startAudioRecorder()
return true
} }
fun tryReleaseAudio() { fun tryReleaseAudio() {

View File

@@ -17,6 +17,7 @@ import android.app.PendingIntent.FLAG_UPDATE_CURRENT
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.content.pm.ServiceInfo
import android.content.res.Configuration import android.content.res.Configuration
import android.content.res.Configuration.ORIENTATION_LANDSCAPE import android.content.res.Configuration.ORIENTATION_LANDSCAPE
import android.graphics.Color import android.graphics.Color
@@ -150,7 +151,7 @@ class MainService : Service() {
if (incomingVoiceCall) { if (incomingVoiceCall) {
voiceCallRequestNotification(id, "Voice Call Request", username, peerId) voiceCallRequestNotification(id, "Voice Call Request", username, peerId)
} else { } else {
if (!audioRecordHandle.switchOutVoiceCall(mediaProjection)) { if (!switchOutVoiceCall()) {
Log.e(logTag, "switchOutVoiceCall fail") Log.e(logTag, "switchOutVoiceCall fail")
MainActivity.flutterMethodChannel?.invokeMethod("msgbox", mapOf( MainActivity.flutterMethodChannel?.invokeMethod("msgbox", mapOf(
"type" to "custom-nook-nocancel-hasclose-error", "type" to "custom-nook-nocancel-hasclose-error",
@@ -159,7 +160,7 @@ class MainService : Service() {
} }
} }
} else { } else {
if (!audioRecordHandle.switchToVoiceCall(mediaProjection)) { if (!switchToVoiceCall()) {
Log.e(logTag, "switchToVoiceCall fail") Log.e(logTag, "switchToVoiceCall fail")
MainActivity.flutterMethodChannel?.invokeMethod("msgbox", mapOf( MainActivity.flutterMethodChannel?.invokeMethod("msgbox", mapOf(
"type" to "custom-nook-nocancel-hasclose-error", "type" to "custom-nook-nocancel-hasclose-error",
@@ -214,17 +215,19 @@ class MainService : Service() {
// video // video
private var mediaProjection: MediaProjection? = null private var mediaProjection: MediaProjection? = null
private val mediaProjectionCallback = object : MediaProjection.Callback() { private var mediaProjectionCallback: MediaProjection.Callback? = null
override fun onStop() { private var captureRestartPending = false
Log.d(logTag, "MediaProjection stopped") private var captureRestartInVoiceCall = false
stopCapture() private val mediaProjectionResultReceiver =
virtualDisplay?.release() object : ResultReceiver(Handler(Looper.getMainLooper())) {
virtualDisplay = null override fun onReceiveResult(resultCode: Int, resultData: Bundle?) {
releaseMediaProjection() if (resultCode == RES_FAILED) {
_isReady = false cancelMediaProjectionRecovery()
checkMediaPermission() }
}
} }
} private var mediaProjectionForegroundService = false
private var microphoneForegroundService = false
private var surface: Surface? = null private var surface: Surface? = null
private val sendVP9Thread = Executors.newSingleThreadExecutor() private val sendVP9Thread = Executors.newSingleThreadExecutor()
private var videoEncoder: MediaCodec? = null private var videoEncoder: MediaCodec? = null
@@ -350,8 +353,6 @@ class MainService : Service() {
Log.d("whichService", "this service: ${Thread.currentThread()}") Log.d("whichService", "this service: ${Thread.currentThread()}")
super.onStartCommand(intent, flags, startId) super.onStartCommand(intent, flags, startId)
if (intent?.action == ACT_INIT_MEDIA_PROJECTION_AND_SERVICE) { if (intent?.action == ACT_INIT_MEDIA_PROJECTION_AND_SERVICE) {
createForegroundNotification()
if (intent.getBooleanExtra(EXT_INIT_FROM_BOOT, false)) { if (intent.getBooleanExtra(EXT_INIT_FROM_BOOT, false)) {
FFI.startService() FFI.startService()
} }
@@ -360,13 +361,7 @@ class MainService : Service() {
getSystemService(MEDIA_PROJECTION_SERVICE) as MediaProjectionManager getSystemService(MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
intent.getParcelableExtra<Intent>(EXT_MEDIA_PROJECTION_RES_INTENT)?.let { intent.getParcelableExtra<Intent>(EXT_MEDIA_PROJECTION_RES_INTENT)?.let {
releaseMediaProjection() replaceMediaProjection(mediaProjectionManager, it)
val projection =
mediaProjectionManager.getMediaProjection(Activity.RESULT_OK, it)
projection.registerCallback(mediaProjectionCallback, Handler(Looper.getMainLooper()))
mediaProjection = projection
_isReady = true
checkMediaPermission()
} ?: let { } ?: let {
Log.d(logTag, "getParcelableExtra intent null, invoke requestMediaProjection") Log.d(logTag, "getParcelableExtra intent null, invoke requestMediaProjection")
requestMediaProjection() requestMediaProjection()
@@ -380,18 +375,21 @@ class MainService : Service() {
updateScreenInfo(newConfig.orientation) updateScreenInfo(newConfig.orientation)
} }
private fun requestMediaProjection() { private fun requestMediaProjection(recovery: Boolean = false) {
val intent = Intent(this, PermissionRequestTransparentActivity::class.java).apply { val intent = Intent(this, PermissionRequestTransparentActivity::class.java).apply {
action = ACT_REQUEST_MEDIA_PROJECTION action = ACT_REQUEST_MEDIA_PROJECTION
flags = Intent.FLAG_ACTIVITY_NEW_TASK flags = Intent.FLAG_ACTIVITY_NEW_TASK
if (recovery) {
putExtra(EXT_MEDIA_PROJECTION_RESULT_RECEIVER, mediaProjectionResultReceiver)
}
} }
startActivity(intent) startActivity(intent)
} }
private fun releaseMediaProjection() { @Synchronized
mediaProjection?.unregisterCallback(mediaProjectionCallback) private fun cancelMediaProjectionRecovery() {
mediaProjection?.stop() captureRestartPending = false
mediaProjection = null captureRestartInVoiceCall = false
} }
@SuppressLint("WrongConstant") @SuppressLint("WrongConstant")
@@ -427,15 +425,149 @@ class MainService : Service() {
} }
} }
fun onVoiceCallStarted(): Boolean { private fun releaseMediaProjection() {
return audioRecordHandle.onVoiceCallStarted(mediaProjection) 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 { fun onVoiceCallClosed(): Boolean {
return audioRecordHandle.onVoiceCallClosed(mediaProjection) captureRestartInVoiceCall = false
return stopMicrophoneCapture {
audioRecordHandle.onVoiceCallClosed(mediaProjection)
}
} }
fun startCapture(): Boolean { fun startCapture(): Boolean {
return startCapture(false)
}
@Synchronized
private fun startCapture(inVoiceCall: Boolean): Boolean {
if (isStart) { if (isStart) {
return true return true
} }
@@ -443,25 +575,35 @@ class MainService : Service() {
Log.w(logTag, "startCapture fail,mediaProjection is null") Log.w(logTag, "startCapture fail,mediaProjection is null")
return false return false
} }
captureRestartInVoiceCall = inVoiceCall
updateScreenInfo(resources.configuration.orientation) updateScreenInfo(resources.configuration.orientation)
Log.d(logTag, "Start Capture") Log.d(logTag, "Start Capture")
surface = createSurface() surface = createSurface()
if (useVP9) { val videoStarted = if (useVP9) {
startVP9VideoRecorder(mediaProjection!!) startVP9VideoRecorder(mediaProjection!!)
} else { } else {
startRawVideoRecorder(mediaProjection!!) startRawVideoRecorder(mediaProjection!!)
} }
if (!videoStarted) {
if (!captureRestartPending) {
captureRestartInVoiceCall = false
}
releaseFailedVideoCapture()
return false
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
if (!audioRecordHandle.createAudioRecorder(false, mediaProjection)) { val audioStarted = if (inVoiceCall) {
Log.d(logTag, "createAudioRecorder fail") switchToVoiceCall()
} else { } else {
Log.d(logTag, "audio recorder start") audioRecordHandle.createAudioRecorder(false, mediaProjection) &&
audioRecordHandle.startAudioRecorder() audioRecordHandle.startAudioRecorder()
} }
Log.d(logTag, if (audioStarted) "audio recorder start" else "audio recorder start failed")
} }
captureRestartInVoiceCall = false
checkMediaPermission() checkMediaPermission()
_isStart = true _isStart = true
FFI.setFrameRawEnable("video",true) FFI.setFrameRawEnable("video",true)
@@ -469,9 +611,24 @@ class MainService : Service() {
return true return true
} }
private fun releaseFailedVideoCapture() {
imageReader?.close()
imageReader = null
videoEncoder?.let {
it.signalEndOfInputStream()
it.stop()
it.release()
}
videoEncoder = null
surface?.release()
surface = null
}
@Synchronized @Synchronized
fun stopCapture() { fun stopCapture() {
Log.d(logTag, "Stop Capture") Log.d(logTag, "Stop Capture")
captureRestartPending = false
captureRestartInVoiceCall = false
FFI.setFrameRawEnable("video",false) FFI.setFrameRawEnable("video",false)
_isStart = false _isStart = false
MainActivity.rdClipboardManager?.setCaptureStarted(_isStart) MainActivity.rdClipboardManager?.setCaptureStarted(_isStart)
@@ -502,8 +659,11 @@ class MainService : Service() {
surface?.release() surface?.release()
// release audio // release audio
_isAudioStart = false stopMicrophoneCapture {
audioRecordHandle.tryReleaseAudio() _isAudioStart = false
audioRecordHandle.tryReleaseAudio()
true
}
} }
fun destroy() { fun destroy() {
@@ -519,6 +679,8 @@ class MainService : Service() {
} }
releaseMediaProjection() releaseMediaProjection()
mediaProjectionForegroundService = false
microphoneForegroundService = false
checkMediaPermission() checkMediaPermission()
stopForeground(true) stopForeground(true)
stopService(Intent(this, FloatingWindowService::class.java)) stopService(Intent(this, FloatingWindowService::class.java))
@@ -541,49 +703,70 @@ class MainService : Service() {
return isReady return isReady
} }
private fun startRawVideoRecorder(mp: MediaProjection) { private fun startRawVideoRecorder(mp: MediaProjection): Boolean {
Log.d(logTag, "startRawVideoRecorder,screen info:$SCREEN_INFO") 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") 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() createMediaCodec()
videoEncoder?.let { val encoder = videoEncoder ?: return false
surface = it.createInputSurface() val inputSurface = encoder.createInputSurface()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { surface = inputSurface
surface!!.setFrameRate(1F, FRAME_RATE_COMPATIBILITY_DEFAULT) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
} inputSurface.setFrameRate(1F, FRAME_RATE_COMPATIBILITY_DEFAULT)
it.setCallback(cb)
it.start()
createOrSetVirtualDisplay(mp, surface!!)
} }
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 // 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. // Reuse virtualDisplay if it exists, to avoid media projection confirmation dialog every connection.
private fun createOrSetVirtualDisplay(mp: MediaProjection, s: Surface) { private fun createOrSetVirtualDisplay(mp: MediaProjection, s: Surface): Boolean {
try { return try {
virtualDisplay?.let { val existingDisplay = virtualDisplay
it.resize(SCREEN_INFO.width, SCREEN_INFO.height, SCREEN_INFO.dpi) if (existingDisplay != null) {
it.setSurface(s) existingDisplay.resize(SCREEN_INFO.width, SCREEN_INFO.height, SCREEN_INFO.dpi)
} ?: let { existingDisplay.setSurface(s)
virtualDisplay = mp.createVirtualDisplay( true
} else {
val display = mp.createVirtualDisplay(
"RustDeskVD", "RustDeskVD",
SCREEN_INFO.width, SCREEN_INFO.height, SCREEN_INFO.dpi, VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, SCREEN_INFO.width, SCREEN_INFO.height, SCREEN_INFO.dpi, VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
s, null, null s, null, null
) )
if (display == null) {
Log.e(logTag, "createOrSetVirtualDisplay failed")
handleVirtualDisplayFailure()
} else {
virtualDisplay = display
true
}
} }
} catch (e: SecurityException) { } catch (e: SecurityException) {
Log.w(logTag, "createOrSetVirtualDisplay: got SecurityException, re-requesting confirmation"); Log.w(logTag, "createOrSetVirtualDisplay: got SecurityException", e)
// This initiates a prompt dialog for the user to confirm screen projection. handleVirtualDisplayFailure()
requestMediaProjection()
} }
} }
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() { private val cb: MediaCodec.Callback = object : MediaCodec.Callback() {
override fun onInputBufferAvailable(codec: MediaCodec, index: Int) {} override fun onInputBufferAvailable(codec: MediaCodec, index: Int) {}
override fun onOutputFormatChanged(codec: MediaCodec, format: MediaFormat) {} override fun onOutputFormatChanged(codec: MediaCodec, format: MediaFormat) {}
@@ -674,7 +857,63 @@ class MainService : Service() {
.setColor(ContextCompat.getColor(this, R.color.primary)) .setColor(ContextCompat.getColor(this, R.color.primary))
.setWhen(System.currentTimeMillis()) .setWhen(System.currentTimeMillis())
.build() .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( private fun loginRequestNotification(

View File

@@ -5,6 +5,7 @@ import android.content.Intent
import android.media.projection.MediaProjectionManager import android.media.projection.MediaProjectionManager
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.ResultReceiver
import android.util.Log import android.util.Log
class PermissionRequestTransparentActivity: Activity() { class PermissionRequestTransparentActivity: Activity() {
@@ -31,7 +32,13 @@ class PermissionRequestTransparentActivity: Activity() {
if (resultCode == RESULT_OK && data != null) { if (resultCode == RESULT_OK && data != null) {
launchService(data) launchService(data)
} else { } 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)
}
} }
} }

View File

@@ -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 ACT_LOGIN_REQ_NOTIFY = "LOGIN_REQ_NOTIFY"
const val EXT_INIT_FROM_BOOT = "EXT_INIT_FROM_BOOT" 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_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" const val EXT_LOGIN_REQ_NOTIFY = "LOGIN_REQ_NOTIFY"
// Activity requestCode // Activity requestCode

View File

@@ -1,4 +1,5 @@
<resources> <resources>
<string name="app_name">RustDesk</string> <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="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> </resources>

View File

@@ -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 { allprojects {
repositories { repositories {
google() google()
@@ -9,6 +35,16 @@ allprojects {
rootProject.buildDir = '../build' rootProject.buildDir = '../build'
subprojects { subprojects {
project.buildDir = "${rootProject.buildDir}/${project.name}" 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 { subprojects {
project.evaluationDependsOn(':app') project.evaluationDependsOn(':app')

View File

@@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists 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

View File

@@ -18,7 +18,7 @@ pluginManagement {
plugins { plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0" 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 id "org.jetbrains.kotlin.android" version "2.1.21" apply false
} }