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

@@ -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'

View File

@@ -12,6 +12,8 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
@@ -89,7 +91,12 @@
<service
android:name=".MainService"
android:enabled="true"
android:foregroundServiceType="mediaProjection" />
android:exported="false"
android:foregroundServiceType="specialUse|mediaProjection|microphone">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="@string/foreground_service_special_use_subtype" />
</service>
<service
android:name=".FloatingWindowService"

View File

@@ -18,7 +18,33 @@ const val AUDIO_SAMPLE_RATE = 48000
const val AUDIO_CHANNEL_MASK = AudioFormat.CHANNEL_IN_STEREO
class AudioRecordHandle(private var context: Context, private var isVideoStart: ()->Boolean, private var isAudioStart: ()->Boolean) {
private val logTag = "LOG_AUDIO_RECORD_HANDLE"
companion object {
private const val LOG_TAG = "LOG_AUDIO_RECORD_HANDLE"
private const val NO_ACTIVE_PUBLISHERS = 0
private var activeAudioFramePublishers = NO_ACTIVE_PUBLISHERS
@Synchronized
private fun acquireAudioFramePublisher() {
if (activeAudioFramePublishers == NO_ACTIVE_PUBLISHERS) {
FFI.setFrameRawEnable("audio", true)
}
activeAudioFramePublishers++
}
@Synchronized
private fun releaseAudioFramePublisher() {
if (activeAudioFramePublishers == NO_ACTIVE_PUBLISHERS) {
Log.e(LOG_TAG, "No active audio frame publisher to release")
return
}
activeAudioFramePublishers--
if (activeAudioFramePublishers == NO_ACTIVE_PUBLISHERS) {
FFI.setFrameRawEnable("audio", false)
}
}
}
private val logTag = LOG_TAG
private var audioRecorder: AudioRecord? = null
private var audioReader: AudioReader? = null
@@ -79,48 +105,94 @@ class AudioRecordHandle(private var context: Context, private var isVideoStart:
return
}
// read f32 to byte , length * 4
minBufferSize = 2 * 4 * AudioRecord.getMinBufferSize(
val bufferSize = 2 * 4 * AudioRecord.getMinBufferSize(
AUDIO_SAMPLE_RATE,
AUDIO_CHANNEL_MASK,
AUDIO_ENCODING
)
if (minBufferSize == 0) {
if (bufferSize <= 0) {
Log.d(logTag, "get min buffer size fail!")
return
}
audioReader = AudioReader(minBufferSize, 4)
audioReader = AudioReader(bufferSize, 4)
minBufferSize = bufferSize
Log.d(logTag, "init audioData len:$minBufferSize")
}
@RequiresApi(Build.VERSION_CODES.M)
fun startAudioRecorder() {
checkAudioReader()
if (audioReader != null && audioRecorder != null && minBufferSize != 0) {
try {
FFI.setFrameRawEnable("audio", true)
audioRecorder!!.startRecording()
audioRecordStat = true
audioThread = thread {
while (audioRecordStat) {
audioReader!!.readSync(audioRecorder!!)?.let {
FFI.onAudioFrameUpdate(it)
}
}
// let's release here rather than onDestroy to avoid threading issue
audioRecorder?.release()
audioRecorder = null
minBufferSize = 0
FFI.setFrameRawEnable("audio", false)
Log.d(logTag, "Exit audio thread")
}
} catch (e: Exception) {
Log.d(logTag, "startAudioRecorder fail:$e")
private fun releaseRecorder(recorder: AudioRecord) {
try {
recorder.release()
} finally {
if (audioRecorder === recorder) {
audioRecorder = null
}
} else {
Log.d(logTag, "startAudioRecorder fail")
}
}
private fun captureAudio(reader: AudioReader, recorder: AudioRecord) {
try {
while (audioRecordStat) {
reader.readSync(recorder)?.let {
FFI.onAudioFrameUpdate(it)
}
}
} finally {
minBufferSize = 0
try {
releaseRecorder(recorder)
} finally {
releaseAudioFramePublisher()
Log.d(logTag, "Exit audio thread")
}
}
}
@RequiresApi(Build.VERSION_CODES.M)
fun startAudioRecorder(): Boolean {
val recorder = audioRecorder
if (recorder == null) {
Log.d(logTag, "startAudioRecorder fail")
return false
}
var audioFramePublisherAcquired = false
return try {
checkAudioReader()
val reader = audioReader
if (reader == null || minBufferSize == 0) {
releaseRecorder(recorder)
Log.d(logTag, "startAudioRecorder fail")
return false
}
recorder.startRecording()
if (recorder.recordingState != AudioRecord.RECORDSTATE_RECORDING) {
throw IllegalStateException("AudioRecord failed to enter recording state")
}
audioRecordStat = true
val captureThread = thread(start = false) { captureAudio(reader, recorder) }
acquireAudioFramePublisher()
audioFramePublisherAcquired = true
audioThread = captureThread
captureThread.start()
true
} catch (error: Exception) {
audioRecordStat = false
audioThread = null
Log.e(logTag, "startAudioRecorder fail", error)
try {
releaseRecorder(recorder)
} finally {
if (audioFramePublisherAcquired) {
releaseAudioFramePublisher()
}
}
false
}
}
fun isVoiceCallActive(): Boolean {
return audioRecorder?.audioSource == MediaRecorder.AudioSource.VOICE_COMMUNICATION
}
fun onVoiceCallStarted(mediaProjection: MediaProjection?): Boolean {
if (!isSupportVoiceCall()) {
return false
@@ -137,11 +209,9 @@ class AudioRecordHandle(private var context: Context, private var isVideoStart:
if (!isSupportVoiceCall()) {
return true
}
if (isVideoStart()) {
switchOutVoiceCall(mediaProjection)
}
val switched = !isVideoStart() || switchOutVoiceCall(mediaProjection)
tryReleaseAudio()
return true
return switched
}
@RequiresApi(Build.VERSION_CODES.M)
@@ -159,8 +229,7 @@ class AudioRecordHandle(private var context: Context, private var isVideoStart:
Log.e(logTag, "createAudioRecorder fail")
return false
}
startAudioRecorder()
return true
return startAudioRecorder()
}
@RequiresApi(Build.VERSION_CODES.M)
@@ -177,8 +246,7 @@ class AudioRecordHandle(private var context: Context, private var isVideoStart:
Log.e(logTag, "createAudioRecorder fail")
return false
}
startAudioRecorder()
return true
return startAudioRecorder()
}
fun tryReleaseAudio() {

View File

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

View File

@@ -5,6 +5,7 @@ import android.content.Intent
import android.media.projection.MediaProjectionManager
import android.os.Build
import android.os.Bundle
import android.os.ResultReceiver
import android.util.Log
class PermissionRequestTransparentActivity: Activity() {
@@ -31,7 +32,13 @@ class PermissionRequestTransparentActivity: Activity() {
if (resultCode == RESULT_OK && data != null) {
launchService(data)
} else {
setResult(RES_FAILED)
val resultReceiver =
intent.getParcelableExtra<ResultReceiver>(EXT_MEDIA_PROJECTION_RESULT_RECEIVER)
if (resultReceiver != null) {
resultReceiver.send(RES_FAILED, null)
} else {
setResult(RES_FAILED)
}
}
}
@@ -51,4 +58,4 @@ class PermissionRequestTransparentActivity: Activity() {
}
}
}
}

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 EXT_INIT_FROM_BOOT = "EXT_INIT_FROM_BOOT"
const val EXT_MEDIA_PROJECTION_RES_INTENT = "MEDIA_PROJECTION_RES_INTENT"
const val EXT_MEDIA_PROJECTION_RESULT_RECEIVER = "MEDIA_PROJECTION_RESULT_RECEIVER"
const val EXT_LOGIN_REQ_NOTIFY = "LOGIN_REQ_NOTIFY"
// Activity requestCode
@@ -164,4 +165,4 @@ fun getScreenSize(windowManager: WindowManager) : Pair<Int, Int>{
fun translate(input: String): String {
Log.d("common", "translate:$LOCAL_NAME")
return FFI.translateLocale(LOCAL_NAME, input)
}
}

View File

@@ -1,4 +1,5 @@
<resources>
<string name="app_name">RustDesk</string>
<string name="accessibility_service_description">Allow other devices to control your phone using virtual touch, when RustDesk screen sharing is established</string>
<string name="foreground_service_special_use_subtype">Keeps the RustDesk remote desktop host available for authorized unattended connections and foreground notifications without starting screen capture before user approval.</string>
</resources>

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 {
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')

View File

@@ -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

View File

@@ -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
}