Initial commit: RustDesk 1.4.7 macOS desktop port

- Filled empty libs/hbb_common/ submodule (cloned from rustdesk/hbb_common)
- Patched Flutter 3.44 / Dart 3.12 compatibility:
  * flutter/lib/generated_bridge.dart: asTypedList with cast<>, DartPort=Int64
  * flutter/lib/common.dart: DialogTheme->DialogThemeData, TabBarTheme->TabBarThemeData
  * flutter/pubspec.yaml: extended_text 14.0.0->15.0.2, google_fonts override 5.0.0
  * flutter/macos/Runner/Configs/Release.xcconfig: EXCLUDED_ARCHS=x86_64
- Build verified: cargo check + cargo build --features flutter + cargo build --release --features flutter
- Verified flutter build macos --debug and --release both produce working .app
- Verified .dmg installer (27MB arm64) created via hdiutil
- Build deps: Xcode 26.5, CocoaPods 1.16.2, Flutter 3.44, VCPKG arm64-osx
This commit is contained in:
xuwenwei
2026-06-08 21:42:20 +08:00
parent 3d8db09123
commit d3b6026bfd
840 changed files with 291780 additions and 1 deletions
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.carriez.flutter_hbb">
<!-- Flutter needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
@@ -0,0 +1,106 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.carriez.flutter_hbb">
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<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" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<queries>
<intent>
<!-- https://developer.android.com/training/package-visibility/use-cases#open-urls-custom-tabs -->
<action android:name="android.support.customtabs.action.CustomTabsService" />
</intent>
</queries>
<application
android:name=".MainApplication"
android:icon="@mipmap/ic_launcher"
android:label="RustDesk"
android:requestLegacyExternalStorage="true"
android:roundIcon="@mipmap/ic_launcher"
android:supportsRtl="true">
<receiver
android:name=".BootReceiver"
android:enabled="true"
android:exported="true">
<intent-filter android:priority="1000">
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.QUICKBOOT_POWERON" />
<!--ACTION_BOOT_COMPLETED for debug test on no root device-->
<action android:name="com.carriez.flutter_hbb.DEBUG_BOOT_COMPLETED" />
</intent-filter>
</receiver>
<service
android:name=".InputService"
android:enabled="true"
android:exported="false"
android:label="RustDesk Input"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
<intent-filter>
<action android:name="android.accessibilityservice.AccessibilityService" />
</intent-filter>
<meta-data
android:name="android.accessibilityservice"
android:resource="@xml/accessibility_service_config" />
</service>
<activity
android:name=".MainActivity"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode|navigation"
android:exported="true"
android:hardwareAccelerated="true"
android:launchMode="singleTop"
android:theme="@style/LaunchTheme"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Intent for deep linking-->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="rustdesk" />
</intent-filter>
</activity>
<activity
android:name=".PermissionRequestTransparentActivity"
android:excludeFromRecents="true"
android:theme="@style/Transparent" />
<service
android:name=".MainService"
android:enabled="true"
android:foregroundServiceType="mediaProjection" />
<service
android:name=".FloatingWindowService"
android:enabled="true" />
<!--
Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java
-->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
</manifest>
@@ -0,0 +1,199 @@
package com.carriez.flutter_hbb
import ffi.FFI
import android.Manifest
import android.content.Context
import android.media.*
import android.content.pm.PackageManager
import android.media.projection.MediaProjection
import androidx.annotation.RequiresApi
import androidx.core.app.ActivityCompat
import android.os.Build
import android.util.Log
import kotlin.concurrent.thread
const val AUDIO_ENCODING = AudioFormat.ENCODING_PCM_FLOAT // ENCODING_OPUS need API 30
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"
private var audioRecorder: AudioRecord? = null
private var audioReader: AudioReader? = null
private var minBufferSize = 0
private var audioRecordStat = false
private var audioThread: Thread? = null
@RequiresApi(Build.VERSION_CODES.M)
fun createAudioRecorder(inVoiceCall: Boolean, mediaProjection: MediaProjection?): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
return false
}
if (ActivityCompat.checkSelfPermission(
context,
Manifest.permission.RECORD_AUDIO
) != PackageManager.PERMISSION_GRANTED
) {
Log.d(logTag, "createAudioRecorder failed, no RECORD_AUDIO permission")
return false
}
var builder = AudioRecord.Builder()
.setAudioFormat(
AudioFormat.Builder()
.setEncoding(AUDIO_ENCODING)
.setSampleRate(AUDIO_SAMPLE_RATE)
.setChannelMask(AUDIO_CHANNEL_MASK).build()
);
if (inVoiceCall) {
builder.setAudioSource(MediaRecorder.AudioSource.VOICE_COMMUNICATION)
} else {
mediaProjection?.let {
var apcc = AudioPlaybackCaptureConfiguration.Builder(it)
.addMatchingUsage(AudioAttributes.USAGE_MEDIA)
.addMatchingUsage(AudioAttributes.USAGE_ALARM)
.addMatchingUsage(AudioAttributes.USAGE_GAME)
.addMatchingUsage(AudioAttributes.USAGE_UNKNOWN).build();
builder.setAudioPlaybackCaptureConfig(apcc);
} ?: let {
Log.d(logTag, "createAudioRecorder failed, mediaProjection null")
return false
}
}
val recorder = try {
builder.build()
} catch (e: Exception) {
Log.e(logTag, "createAudioRecorder failed", e)
return false
}
audioRecorder = recorder
Log.d(logTag, "createAudioRecorder done,minBufferSize:$minBufferSize")
return true
}
@RequiresApi(Build.VERSION_CODES.M)
private fun checkAudioReader() {
if (audioReader != null && minBufferSize != 0) {
return
}
// read f32 to byte , length * 4
minBufferSize = 2 * 4 * AudioRecord.getMinBufferSize(
AUDIO_SAMPLE_RATE,
AUDIO_CHANNEL_MASK,
AUDIO_ENCODING
)
if (minBufferSize == 0) {
Log.d(logTag, "get min buffer size fail!")
return
}
audioReader = AudioReader(minBufferSize, 4)
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")
}
} else {
Log.d(logTag, "startAudioRecorder fail")
}
}
fun onVoiceCallStarted(mediaProjection: MediaProjection?): Boolean {
if (!isSupportVoiceCall()) {
return false
}
// No need to check if video or audio is started here.
if (!switchToVoiceCall(mediaProjection)) {
return false
}
return true
}
fun onVoiceCallClosed(mediaProjection: MediaProjection?): Boolean {
// Return true if not supported, because is was not started.
if (!isSupportVoiceCall()) {
return true
}
if (isVideoStart()) {
switchOutVoiceCall(mediaProjection)
}
tryReleaseAudio()
return true
}
@RequiresApi(Build.VERSION_CODES.M)
fun switchToVoiceCall(mediaProjection: MediaProjection?): Boolean {
audioRecorder?.let {
if (it.getAudioSource() == MediaRecorder.AudioSource.VOICE_COMMUNICATION) {
return true
}
}
audioRecordStat = false
audioThread?.join()
audioThread = null
if (!createAudioRecorder(true, mediaProjection)) {
Log.e(logTag, "createAudioRecorder fail")
return false
}
startAudioRecorder()
return true
}
@RequiresApi(Build.VERSION_CODES.M)
fun switchOutVoiceCall(mediaProjection: MediaProjection?): Boolean {
audioRecorder?.let {
if (it.getAudioSource() != MediaRecorder.AudioSource.VOICE_COMMUNICATION) {
return true
}
}
audioRecordStat = false
audioThread?.join()
if (!createAudioRecorder(false, mediaProjection)) {
Log.e(logTag, "createAudioRecorder fail")
return false
}
startAudioRecorder()
return true
}
fun tryReleaseAudio() {
if (isAudioStart() || isVideoStart()) {
return
}
audioRecordStat = false
audioThread?.join()
audioThread = null
}
fun destroy() {
Log.d(logTag, "destroy audio record handle")
audioRecordStat = false
audioThread?.join()
}
}
@@ -0,0 +1,47 @@
package com.carriez.flutter_hbb
import android.Manifest.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS
import android.Manifest.permission.SYSTEM_ALERT_WINDOW
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Build
import android.util.Log
import android.widget.Toast
import com.hjq.permissions.XXPermissions
import io.flutter.embedding.android.FlutterActivity
const val DEBUG_BOOT_COMPLETED = "com.carriez.flutter_hbb.DEBUG_BOOT_COMPLETED"
class BootReceiver : BroadcastReceiver() {
private val logTag = "tagBootReceiver"
override fun onReceive(context: Context, intent: Intent) {
Log.d(logTag, "onReceive ${intent.action}")
if (Intent.ACTION_BOOT_COMPLETED == intent.action || DEBUG_BOOT_COMPLETED == intent.action) {
// check SharedPreferences config
val prefs = context.getSharedPreferences(KEY_SHARED_PREFERENCES, FlutterActivity.MODE_PRIVATE)
if (!prefs.getBoolean(KEY_START_ON_BOOT_OPT, false)) {
Log.d(logTag, "KEY_START_ON_BOOT_OPT is false")
return
}
// check pre-permission
if (!XXPermissions.isGranted(context, REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, SYSTEM_ALERT_WINDOW)){
Log.d(logTag, "REQUEST_IGNORE_BATTERY_OPTIMIZATIONS or SYSTEM_ALERT_WINDOW is not granted")
return
}
val it = Intent(context, MainService::class.java).apply {
action = ACT_INIT_MEDIA_PROJECTION_AND_SERVICE
putExtra(EXT_INIT_FROM_BOOT, true)
}
Toast.makeText(context, "RustDesk is Open", Toast.LENGTH_LONG).show()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(it)
} else {
context.startService(it)
}
}
}
}
@@ -0,0 +1,394 @@
package com.carriez.flutter_hbb
import android.annotation.SuppressLint
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.content.res.Configuration
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.PixelFormat
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.util.Log
import android.view.Gravity
import android.view.MotionEvent
import android.view.View
import android.view.WindowManager
import android.view.WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN
import android.view.WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
import android.view.WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL
import android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
import android.widget.ImageView
import android.widget.PopupMenu
import com.caverock.androidsvg.SVG
import ffi.FFI
import kotlin.math.abs
class FloatingWindowService : Service(), View.OnTouchListener {
private lateinit var windowManager: WindowManager
private lateinit var layoutParams: WindowManager.LayoutParams
private lateinit var floatingView: ImageView
private lateinit var originalDrawable: Drawable
private lateinit var leftHalfDrawable: Drawable
private lateinit var rightHalfDrawable: Drawable
private var dragging = false
private var lastDownX = 0f
private var lastDownY = 0f
private var viewCreated = false;
private var keepScreenOn = KeepScreenOn.DURING_CONTROLLED
companion object {
private val logTag = "floatingService"
private var firstCreate = true
private var viewWidth = 120
private var viewHeight = 120
private const val MIN_VIEW_SIZE = 32 // size 0 does not help prevent the service from being killed
private const val MAX_VIEW_SIZE = 320
private var viewUntouchable = false
private var viewTransparency = 1f // 0 means invisible but can help prevent the service from being killed
private var customSvg = ""
private var lastLayoutX = 0
private var lastLayoutY = 0
private var lastOrientation = Configuration.ORIENTATION_UNDEFINED
}
override fun onBind(intent: Intent): IBinder? {
return null
}
override fun onCreate() {
super.onCreate()
windowManager = getSystemService(WINDOW_SERVICE) as WindowManager
try {
if (firstCreate) {
firstCreate = false
onFirstCreate(windowManager)
}
Log.d(logTag, "floating window size: $viewWidth x $viewHeight, transparency: $viewTransparency, lastLayoutX: $lastLayoutX, lastLayoutY: $lastLayoutY, customSvg: $customSvg")
createView(windowManager)
handler.postDelayed(runnable, 1000)
Log.d(logTag, "onCreate success")
} catch (e: Exception) {
Log.d(logTag, "onCreate failed: $e")
}
}
override fun onDestroy() {
super.onDestroy()
if (viewCreated) {
windowManager.removeView(floatingView)
}
handler.removeCallbacks(runnable)
}
@SuppressLint("ClickableViewAccessibility")
private fun createView(windowManager: WindowManager) {
floatingView = ImageView(this)
viewCreated = true
originalDrawable = resources.getDrawable(R.drawable.floating_window, null)
if (customSvg.isNotEmpty()) {
try {
val svg = SVG.getFromString(customSvg)
Log.d(logTag, "custom svg info: ${svg.documentWidth} x ${svg.documentHeight}");
// This make the svg render clear
svg.documentWidth = viewWidth * 1f
svg.documentHeight = viewHeight * 1f
originalDrawable = svg.renderToPicture().let {
BitmapDrawable(
resources,
Bitmap.createBitmap(it.width, it.height, Bitmap.Config.ARGB_8888)
.also { bitmap ->
it.draw(Canvas(bitmap))
})
}
floatingView.setLayerType(View.LAYER_TYPE_SOFTWARE, null);
Log.d(logTag, "custom svg loaded")
} catch (e: Exception) {
e.printStackTrace()
}
}
val originalBitmap = Bitmap.createBitmap(
originalDrawable.intrinsicWidth,
originalDrawable.intrinsicHeight,
Bitmap.Config.ARGB_8888
)
val canvas = Canvas(originalBitmap)
originalDrawable.setBounds(
0,
0,
originalDrawable.intrinsicWidth,
originalDrawable.intrinsicHeight
)
originalDrawable.draw(canvas)
val leftHalfBitmap = Bitmap.createBitmap(
originalBitmap,
0,
0,
originalDrawable.intrinsicWidth / 2,
originalDrawable.intrinsicHeight
)
val rightHalfBitmap = Bitmap.createBitmap(
originalBitmap,
originalDrawable.intrinsicWidth / 2,
0,
originalDrawable.intrinsicWidth / 2,
originalDrawable.intrinsicHeight
)
leftHalfDrawable = BitmapDrawable(resources, leftHalfBitmap)
rightHalfDrawable = BitmapDrawable(resources, rightHalfBitmap)
floatingView.setImageDrawable(rightHalfDrawable)
floatingView.setOnTouchListener(this)
floatingView.alpha = viewTransparency * 1f
var flags = FLAG_LAYOUT_IN_SCREEN or FLAG_NOT_TOUCH_MODAL or FLAG_NOT_FOCUSABLE
if (viewUntouchable || viewTransparency == 0f) {
flags = flags or WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE
}
layoutParams = WindowManager.LayoutParams(
viewWidth / 2,
viewHeight,
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY else WindowManager.LayoutParams.TYPE_PHONE,
flags,
PixelFormat.TRANSLUCENT
)
layoutParams.gravity = Gravity.TOP or Gravity.START
layoutParams.x = lastLayoutX
layoutParams.y = lastLayoutY
val keepScreenOnOption = FFI.getLocalOption("keep-screen-on").lowercase()
keepScreenOn = when (keepScreenOnOption) {
"never" -> KeepScreenOn.NEVER
"service-on" -> KeepScreenOn.SERVICE_ON
else -> KeepScreenOn.DURING_CONTROLLED
}
Log.d(logTag, "keepScreenOn option: $keepScreenOnOption, value: $keepScreenOn")
updateKeepScreenOnLayoutParams()
windowManager.addView(floatingView, layoutParams)
moveToScreenSide()
}
private fun onFirstCreate(windowManager: WindowManager) {
val wh = getScreenSize(windowManager)
val w = wh.first
val h = wh.second
// size
FFI.getLocalOption("floating-window-size").let {
if (it.isNotEmpty()) {
try {
val size = it.toInt()
if (size in MIN_VIEW_SIZE..MAX_VIEW_SIZE && size <= w / 2 && size <= h / 2) {
viewWidth = size
viewHeight = size
}
} catch (e: Exception) {
e.printStackTrace()
}
}
}
// untouchable
viewUntouchable = FFI.getLocalOption("floating-window-untouchable") == "Y"
// transparency
FFI.getLocalOption("floating-window-transparency").let {
if (it.isNotEmpty()) {
try {
val transparency = it.toInt()
if (transparency in 0..10) {
viewTransparency = transparency * 1f / 10
}
} catch (e: Exception) {
e.printStackTrace()
}
}
}
// custom svg
FFI.getLocalOption("floating-window-svg").let {
if (it.isNotEmpty()) {
customSvg = it
}
}
// position
lastLayoutX = 0
lastLayoutY = (wh.second - viewHeight) / 2
lastOrientation = resources.configuration.orientation
}
private fun performClick() {
showPopupMenu()
}
override fun onTouch(view: View?, event: MotionEvent?): Boolean {
when (event?.action) {
MotionEvent.ACTION_DOWN -> {
dragging = false
lastDownX = event.rawX
lastDownY = event.rawY
}
MotionEvent.ACTION_UP -> {
val clickDragTolerance = 10f
if (abs(event.rawX - lastDownX) < clickDragTolerance && abs(event.rawY - lastDownY) < clickDragTolerance) {
performClick()
} else {
moveToScreenSide()
}
}
MotionEvent.ACTION_MOVE -> {
val dx = event.rawX - lastDownX
val dy = event.rawY - lastDownY
// ignore too small fist start moving(some time is click)
if (!dragging && dx*dx+dy*dy < 25) {
return false
}
dragging = true
layoutParams.x = event.rawX.toInt()
layoutParams.y = event.rawY.toInt()
layoutParams.width = viewWidth
floatingView.setImageDrawable(originalDrawable)
windowManager.updateViewLayout(view, layoutParams)
lastLayoutX = layoutParams.x
lastLayoutY = layoutParams.y
}
}
return false
}
private fun moveToScreenSide(center: Boolean = false) {
val windowManager = getSystemService(WINDOW_SERVICE) as WindowManager
val wh = getScreenSize(windowManager)
val w = wh.first
if (layoutParams.x < w / 2) {
layoutParams.x = 0
floatingView.setImageDrawable(rightHalfDrawable)
} else {
layoutParams.x = w - viewWidth / 2
floatingView.setImageDrawable(leftHalfDrawable)
}
if (center) {
layoutParams.y = (wh.second - viewHeight) / 2
}
layoutParams.width = viewWidth / 2
windowManager.updateViewLayout(floatingView, layoutParams)
lastLayoutX = layoutParams.x
lastLayoutY = layoutParams.y
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
if (newConfig.orientation != lastOrientation) {
lastOrientation = newConfig.orientation
val wh = getScreenSize(windowManager)
Log.d(logTag, "orientation: $lastOrientation, screen size: ${wh.first} x ${wh.second}")
val newW = wh.first
val newH = wh.second
if (newConfig.orientation == Configuration.ORIENTATION_LANDSCAPE || newConfig.orientation == Configuration.ORIENTATION_PORTRAIT) {
// Proportional change
layoutParams.x = (layoutParams.x.toFloat() / newH.toFloat() * newW.toFloat()).toInt()
layoutParams.y = (layoutParams.y.toFloat() / newW.toFloat() * newH.toFloat()).toInt()
}
moveToScreenSide()
}
}
private fun showPopupMenu() {
val popupMenu = PopupMenu(this, floatingView)
val idShowRustDesk = 0
popupMenu.menu.add(0, idShowRustDesk, 0, translate("Show RustDesk"))
// For host side, clipboard sync
val idSyncClipboard = 1
val isServiceSyncEnabled = (MainActivity.rdClipboardManager?.isCaptureStarted ?: false) && FFI.isServiceClipboardEnabled()
if (isServiceSyncEnabled) {
popupMenu.menu.add(0, idSyncClipboard, 0, translate("Update client clipboard"))
}
val idStopService = 2
val hideStopService = FFI.getBuildinOption("hide-stop-service") == "Y"
if (!hideStopService) {
popupMenu.menu.add(0, idStopService, 0, translate("Stop service"))
}
popupMenu.setOnMenuItemClickListener { menuItem ->
when (menuItem.itemId) {
idShowRustDesk -> {
openMainActivity()
true
}
idSyncClipboard -> {
syncClipboard()
true
}
idStopService -> {
stopMainService()
true
}
else -> false
}
}
popupMenu.setOnDismissListener {
moveToScreenSide()
}
popupMenu.show()
}
private fun openMainActivity() {
val intent = Intent(this, MainActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
val pendingIntent = PendingIntent.getActivity(
this, 0, intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_ONE_SHOT
)
try {
pendingIntent.send()
} catch (e: PendingIntent.CanceledException) {
e.printStackTrace()
}
}
private fun syncClipboard() {
MainActivity.rdClipboardManager?.syncClipboard(false)
}
private fun stopMainService() {
MainActivity.flutterMethodChannel?.invokeMethod("stop_service", null)
}
enum class KeepScreenOn {
NEVER,
DURING_CONTROLLED,
SERVICE_ON,
}
private val handler = Handler(Looper.getMainLooper())
private val runnable = object : Runnable {
override fun run() {
if (updateKeepScreenOnLayoutParams()) {
windowManager.updateViewLayout(floatingView, layoutParams)
}
handler.postDelayed(this, 1000) // 1000 milliseconds = 1 second
}
}
private fun updateKeepScreenOnLayoutParams(): Boolean {
val oldOn = layoutParams.flags and FLAG_KEEP_SCREEN_ON != 0
val newOn = keepScreenOn == KeepScreenOn.SERVICE_ON || (keepScreenOn == KeepScreenOn.DURING_CONTROLLED && MainService.isStart)
if (oldOn != newOn) {
Log.d(logTag, "change keep screen on to $newOn")
if (newOn) {
layoutParams.flags = layoutParams.flags or FLAG_KEEP_SCREEN_ON
} else {
layoutParams.flags = layoutParams.flags and FLAG_KEEP_SCREEN_ON.inv()
}
return true
}
return false
}
}
@@ -0,0 +1,741 @@
package com.carriez.flutter_hbb
/**
* Handle remote input and dispatch android gesture
*
* Inspired by [droidVNC-NG] https://github.com/bk138/droidVNC-NG
*/
import android.accessibilityservice.AccessibilityService
import android.accessibilityservice.GestureDescription
import android.graphics.Path
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.widget.EditText
import android.view.accessibility.AccessibilityEvent
import android.view.ViewGroup.LayoutParams
import android.view.accessibility.AccessibilityNodeInfo
import android.view.KeyEvent as KeyEventAndroid
import android.view.ViewConfiguration
import android.graphics.Rect
import android.media.AudioManager
import android.accessibilityservice.AccessibilityServiceInfo
import android.accessibilityservice.AccessibilityServiceInfo.FLAG_INPUT_METHOD_EDITOR
import android.accessibilityservice.AccessibilityServiceInfo.FLAG_RETRIEVE_INTERACTIVE_WINDOWS
import android.view.inputmethod.EditorInfo
import androidx.annotation.RequiresApi
import java.util.*
import java.lang.Character
import kotlin.math.abs
import kotlin.math.max
import hbb.MessageOuterClass.KeyEvent
import hbb.MessageOuterClass.KeyboardMode
import hbb.KeyEventConverter
// const val BUTTON_UP = 2
// const val BUTTON_BACK = 0x08
const val LEFT_DOWN = 9
const val LEFT_MOVE = 8
const val LEFT_UP = 10
const val RIGHT_UP = 18
// (BUTTON_BACK << 3) | BUTTON_UP
const val BACK_UP = 66
const val WHEEL_BUTTON_DOWN = 33
const val WHEEL_BUTTON_UP = 34
const val WHEEL_DOWN = 523331
const val WHEEL_UP = 963
const val TOUCH_SCALE_START = 1
const val TOUCH_SCALE = 2
const val TOUCH_SCALE_END = 3
const val TOUCH_PAN_START = 4
const val TOUCH_PAN_UPDATE = 5
const val TOUCH_PAN_END = 6
const val WHEEL_STEP = 120
const val WHEEL_DURATION = 50L
const val LONG_TAP_DELAY = 200L
class InputService : AccessibilityService() {
companion object {
var ctx: InputService? = null
val isOpen: Boolean
get() = ctx != null
}
private val logTag = "input service"
private var leftIsDown = false
private var touchPath = Path()
private var stroke: GestureDescription.StrokeDescription? = null
private var lastTouchGestureStartTime = 0L
private var mouseX = 0
private var mouseY = 0
private var timer = Timer()
private var recentActionTask: TimerTask? = null
// 100(tap timeout) + 400(long press timeout)
private val longPressDuration = ViewConfiguration.getTapTimeout().toLong() + ViewConfiguration.getLongPressTimeout().toLong()
private val wheelActionsQueue = LinkedList<GestureDescription>()
private var isWheelActionsPolling = false
private var isWaitingLongPress = false
private var fakeEditTextForTextStateCalculation: EditText? = null
private var lastX = 0
private var lastY = 0
private val volumeController: VolumeController by lazy { VolumeController(applicationContext.getSystemService(AUDIO_SERVICE) as AudioManager) }
@RequiresApi(Build.VERSION_CODES.N)
fun onMouseInput(mask: Int, _x: Int, _y: Int) {
val x = max(0, _x)
val y = max(0, _y)
if (mask == 0 || mask == LEFT_MOVE) {
val oldX = mouseX
val oldY = mouseY
mouseX = x * SCREEN_INFO.scale
mouseY = y * SCREEN_INFO.scale
if (isWaitingLongPress) {
val delta = abs(oldX - mouseX) + abs(oldY - mouseY)
Log.d(logTag,"delta:$delta")
if (delta > 8) {
isWaitingLongPress = false
}
}
}
// left button down, was up
if (mask == LEFT_DOWN) {
isWaitingLongPress = true
timer.schedule(object : TimerTask() {
override fun run() {
if (isWaitingLongPress) {
isWaitingLongPress = false
continueGesture(mouseX, mouseY)
}
}
}, longPressDuration)
leftIsDown = true
startGesture(mouseX, mouseY)
return
}
// left down, was down
if (leftIsDown) {
continueGesture(mouseX, mouseY)
}
// left up, was down
if (mask == LEFT_UP) {
if (leftIsDown) {
leftIsDown = false
isWaitingLongPress = false
endGesture(mouseX, mouseY)
return
}
}
if (mask == RIGHT_UP) {
longPress(mouseX, mouseY)
return
}
if (mask == BACK_UP) {
performGlobalAction(GLOBAL_ACTION_BACK)
return
}
// long WHEEL_BUTTON_DOWN -> GLOBAL_ACTION_RECENTS
if (mask == WHEEL_BUTTON_DOWN) {
timer.purge()
recentActionTask = object : TimerTask() {
override fun run() {
performGlobalAction(GLOBAL_ACTION_RECENTS)
recentActionTask = null
}
}
timer.schedule(recentActionTask, LONG_TAP_DELAY)
}
// wheel button up
if (mask == WHEEL_BUTTON_UP) {
if (recentActionTask != null) {
recentActionTask!!.cancel()
performGlobalAction(GLOBAL_ACTION_HOME)
}
return
}
if (mask == WHEEL_DOWN) {
if (mouseY < WHEEL_STEP) {
return
}
val path = Path()
path.moveTo(mouseX.toFloat(), mouseY.toFloat())
path.lineTo(mouseX.toFloat(), (mouseY - WHEEL_STEP).toFloat())
val stroke = GestureDescription.StrokeDescription(
path,
0,
WHEEL_DURATION
)
val builder = GestureDescription.Builder()
builder.addStroke(stroke)
wheelActionsQueue.offer(builder.build())
consumeWheelActions()
}
if (mask == WHEEL_UP) {
if (mouseY < WHEEL_STEP) {
return
}
val path = Path()
path.moveTo(mouseX.toFloat(), mouseY.toFloat())
path.lineTo(mouseX.toFloat(), (mouseY + WHEEL_STEP).toFloat())
val stroke = GestureDescription.StrokeDescription(
path,
0,
WHEEL_DURATION
)
val builder = GestureDescription.Builder()
builder.addStroke(stroke)
wheelActionsQueue.offer(builder.build())
consumeWheelActions()
}
}
@RequiresApi(Build.VERSION_CODES.N)
fun onTouchInput(mask: Int, _x: Int, _y: Int) {
when (mask) {
TOUCH_PAN_UPDATE -> {
mouseX -= _x * SCREEN_INFO.scale
mouseY -= _y * SCREEN_INFO.scale
mouseX = max(0, mouseX);
mouseY = max(0, mouseY);
continueGesture(mouseX, mouseY)
}
TOUCH_PAN_START -> {
mouseX = max(0, _x) * SCREEN_INFO.scale
mouseY = max(0, _y) * SCREEN_INFO.scale
startGesture(mouseX, mouseY)
}
TOUCH_PAN_END -> {
endGesture(mouseX, mouseY)
mouseX = max(0, _x) * SCREEN_INFO.scale
mouseY = max(0, _y) * SCREEN_INFO.scale
}
else -> {}
}
}
@RequiresApi(Build.VERSION_CODES.N)
private fun consumeWheelActions() {
if (isWheelActionsPolling) {
return
} else {
isWheelActionsPolling = true
}
wheelActionsQueue.poll()?.let {
dispatchGesture(it, null, null)
timer.purge()
timer.schedule(object : TimerTask() {
override fun run() {
isWheelActionsPolling = false
consumeWheelActions()
}
}, WHEEL_DURATION + 10)
} ?: let {
isWheelActionsPolling = false
return
}
}
@RequiresApi(Build.VERSION_CODES.N)
private fun performClick(x: Int, y: Int, duration: Long) {
val path = Path()
path.moveTo(x.toFloat(), y.toFloat())
try {
val longPressStroke = GestureDescription.StrokeDescription(path, 0, duration)
val builder = GestureDescription.Builder()
builder.addStroke(longPressStroke)
Log.d(logTag, "performClick x:$x y:$y time:$duration")
dispatchGesture(builder.build(), null, null)
} catch (e: Exception) {
Log.e(logTag, "performClick, error:$e")
}
}
@RequiresApi(Build.VERSION_CODES.N)
private fun longPress(x: Int, y: Int) {
performClick(x, y, longPressDuration)
}
private fun startGesture(x: Int, y: Int) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
touchPath.reset()
} else {
touchPath = Path()
}
touchPath.moveTo(x.toFloat(), y.toFloat())
lastTouchGestureStartTime = System.currentTimeMillis()
lastX = x
lastY = y
}
@RequiresApi(Build.VERSION_CODES.N)
private fun doDispatchGesture(x: Int, y: Int, willContinue: Boolean) {
touchPath.lineTo(x.toFloat(), y.toFloat())
var duration = System.currentTimeMillis() - lastTouchGestureStartTime
if (duration <= 0) {
duration = 1
}
try {
if (stroke == null) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
stroke = GestureDescription.StrokeDescription(
touchPath,
0,
duration,
willContinue
)
} else {
stroke = GestureDescription.StrokeDescription(
touchPath,
0,
duration
)
}
} else {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
stroke = stroke?.continueStroke(touchPath, 0, duration, willContinue)
} else {
stroke = null
stroke = GestureDescription.StrokeDescription(
touchPath,
0,
duration
)
}
}
stroke?.let {
val builder = GestureDescription.Builder()
builder.addStroke(it)
Log.d(logTag, "doDispatchGesture x:$x y:$y time:$duration")
dispatchGesture(builder.build(), null, null)
}
} catch (e: Exception) {
Log.e(logTag, "doDispatchGesture, willContinue:$willContinue, error:$e")
}
}
@RequiresApi(Build.VERSION_CODES.N)
private fun continueGesture(x: Int, y: Int) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
doDispatchGesture(x, y, true)
touchPath.reset()
touchPath.moveTo(x.toFloat(), y.toFloat())
lastTouchGestureStartTime = System.currentTimeMillis()
lastX = x
lastY = y
} else {
touchPath.lineTo(x.toFloat(), y.toFloat())
}
}
@RequiresApi(Build.VERSION_CODES.N)
private fun endGestureBelowO(x: Int, y: Int) {
try {
touchPath.lineTo(x.toFloat(), y.toFloat())
var duration = System.currentTimeMillis() - lastTouchGestureStartTime
if (duration <= 0) {
duration = 1
}
val stroke = GestureDescription.StrokeDescription(
touchPath,
0,
duration
)
val builder = GestureDescription.Builder()
builder.addStroke(stroke)
Log.d(logTag, "end gesture x:$x y:$y time:$duration")
dispatchGesture(builder.build(), null, null)
} catch (e: Exception) {
Log.e(logTag, "endGesture error:$e")
}
}
@RequiresApi(Build.VERSION_CODES.N)
private fun endGesture(x: Int, y: Int) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
doDispatchGesture(x, y, false)
touchPath.reset()
stroke = null
} else {
endGestureBelowO(x, y)
}
}
@RequiresApi(Build.VERSION_CODES.N)
fun onKeyEvent(data: ByteArray) {
val keyEvent = KeyEvent.parseFrom(data)
val keyboardMode = keyEvent.getMode()
var textToCommit: String? = null
// [down] indicates the key's state(down or up).
// [press] indicates a click event(down and up).
// https://github.com/rustdesk/rustdesk/blob/3a7594755341f023f56fa4b6a43b60d6b47df88d/flutter/lib/models/input_model.dart#L688
if (keyEvent.hasSeq()) {
textToCommit = keyEvent.getSeq()
} else if (keyboardMode == KeyboardMode.Legacy) {
if (keyEvent.hasChr() && (keyEvent.getDown() || keyEvent.getPress())) {
val chr = keyEvent.getChr()
if (chr != null) {
textToCommit = String(Character.toChars(chr))
}
}
} else if (keyboardMode == KeyboardMode.Translate) {
} else {
}
Log.d(logTag, "onKeyEvent $keyEvent textToCommit:$textToCommit")
var ke: KeyEventAndroid? = null
if (Build.VERSION.SDK_INT < 33 || textToCommit == null) {
ke = KeyEventConverter.toAndroidKeyEvent(keyEvent)
}
ke?.let { event ->
if (tryHandleVolumeKeyEvent(event)) {
return
} else if (tryHandlePowerKeyEvent(event)) {
return
}
}
if (Build.VERSION.SDK_INT >= 33) {
getInputMethod()?.let { inputMethod ->
inputMethod.getCurrentInputConnection()?.let { inputConnection ->
if (textToCommit != null) {
textToCommit?.let { text ->
inputConnection.commitText(text, 1, null)
}
} else {
ke?.let { event ->
inputConnection.sendKeyEvent(event)
if (keyEvent.getPress()) {
val actionUpEvent = KeyEventAndroid(KeyEventAndroid.ACTION_UP, event.keyCode)
inputConnection.sendKeyEvent(actionUpEvent)
}
}
}
}
}
} else {
val handler = Handler(Looper.getMainLooper())
handler.post {
ke?.let { event ->
val possibleNodes = possibleAccessibiltyNodes()
Log.d(logTag, "possibleNodes:$possibleNodes")
for (item in possibleNodes) {
val success = trySendKeyEvent(event, item, textToCommit)
if (success) {
if (keyEvent.getPress()) {
val actionUpEvent = KeyEventAndroid(KeyEventAndroid.ACTION_UP, event.keyCode)
trySendKeyEvent(actionUpEvent, item, textToCommit)
}
break
}
}
}
}
}
}
private fun tryHandleVolumeKeyEvent(event: KeyEventAndroid): Boolean {
when (event.keyCode) {
KeyEventAndroid.KEYCODE_VOLUME_UP -> {
if (event.action == KeyEventAndroid.ACTION_DOWN) {
volumeController.raiseVolume(null, true, AudioManager.STREAM_SYSTEM)
}
return true
}
KeyEventAndroid.KEYCODE_VOLUME_DOWN -> {
if (event.action == KeyEventAndroid.ACTION_DOWN) {
volumeController.lowerVolume(null, true, AudioManager.STREAM_SYSTEM)
}
return true
}
KeyEventAndroid.KEYCODE_VOLUME_MUTE -> {
if (event.action == KeyEventAndroid.ACTION_DOWN) {
volumeController.toggleMute(true, AudioManager.STREAM_SYSTEM)
}
return true
}
else -> {
return false
}
}
}
private fun tryHandlePowerKeyEvent(event: KeyEventAndroid): Boolean {
if (event.keyCode == KeyEventAndroid.KEYCODE_POWER) {
// Perform power dialog action when action is up
if (event.action == KeyEventAndroid.ACTION_UP) {
performGlobalAction(GLOBAL_ACTION_POWER_DIALOG);
}
return true
}
return false
}
private fun insertAccessibilityNode(list: LinkedList<AccessibilityNodeInfo>, node: AccessibilityNodeInfo) {
if (node == null) {
return
}
if (list.contains(node)) {
return
}
list.add(node)
}
private fun findChildNode(node: AccessibilityNodeInfo?): AccessibilityNodeInfo? {
if (node == null) {
return null
}
if (node.isEditable() && node.isFocusable()) {
return node
}
val childCount = node.getChildCount()
for (i in 0 until childCount) {
val child = node.getChild(i)
if (child != null) {
if (child.isEditable() && child.isFocusable()) {
return child
}
if (Build.VERSION.SDK_INT < 33) {
child.recycle()
}
}
}
for (i in 0 until childCount) {
val child = node.getChild(i)
if (child != null) {
val result = findChildNode(child)
if (Build.VERSION.SDK_INT < 33) {
if (child != result) {
child.recycle()
}
}
if (result != null) {
return result
}
}
}
return null
}
private fun possibleAccessibiltyNodes(): LinkedList<AccessibilityNodeInfo> {
val linkedList = LinkedList<AccessibilityNodeInfo>()
val latestList = LinkedList<AccessibilityNodeInfo>()
val focusInput = findFocus(AccessibilityNodeInfo.FOCUS_INPUT)
var focusAccessibilityInput = findFocus(AccessibilityNodeInfo.FOCUS_ACCESSIBILITY)
val rootInActiveWindow = getRootInActiveWindow()
Log.d(logTag, "focusInput:$focusInput focusAccessibilityInput:$focusAccessibilityInput rootInActiveWindow:$rootInActiveWindow")
if (focusInput != null) {
if (focusInput.isFocusable() && focusInput.isEditable()) {
insertAccessibilityNode(linkedList, focusInput)
} else {
insertAccessibilityNode(latestList, focusInput)
}
}
if (focusAccessibilityInput != null) {
if (focusAccessibilityInput.isFocusable() && focusAccessibilityInput.isEditable()) {
insertAccessibilityNode(linkedList, focusAccessibilityInput)
} else {
insertAccessibilityNode(latestList, focusAccessibilityInput)
}
}
val childFromFocusInput = findChildNode(focusInput)
Log.d(logTag, "childFromFocusInput:$childFromFocusInput")
if (childFromFocusInput != null) {
insertAccessibilityNode(linkedList, childFromFocusInput)
}
val childFromFocusAccessibilityInput = findChildNode(focusAccessibilityInput)
if (childFromFocusAccessibilityInput != null) {
insertAccessibilityNode(linkedList, childFromFocusAccessibilityInput)
}
Log.d(logTag, "childFromFocusAccessibilityInput:$childFromFocusAccessibilityInput")
if (rootInActiveWindow != null) {
insertAccessibilityNode(linkedList, rootInActiveWindow)
}
for (item in latestList) {
insertAccessibilityNode(linkedList, item)
}
return linkedList
}
private fun trySendKeyEvent(event: KeyEventAndroid, node: AccessibilityNodeInfo, textToCommit: String?): Boolean {
node.refresh()
this.fakeEditTextForTextStateCalculation?.setSelection(0,0)
this.fakeEditTextForTextStateCalculation?.setText(null)
val text = node.getText()
var isShowingHint = false
if (Build.VERSION.SDK_INT >= 26) {
isShowingHint = node.isShowingHintText()
}
var textSelectionStart = node.textSelectionStart
var textSelectionEnd = node.textSelectionEnd
if (text != null) {
if (textSelectionStart > text.length) {
textSelectionStart = text.length
}
if (textSelectionEnd > text.length) {
textSelectionEnd = text.length
}
if (textSelectionStart > textSelectionEnd) {
textSelectionStart = textSelectionEnd
}
}
var success = false
Log.d(logTag, "existing text:$text textToCommit:$textToCommit textSelectionStart:$textSelectionStart textSelectionEnd:$textSelectionEnd")
if (textToCommit != null) {
if ((textSelectionStart == -1) || (textSelectionEnd == -1)) {
val newText = textToCommit
this.fakeEditTextForTextStateCalculation?.setText(newText)
success = updateTextForAccessibilityNode(node)
} else if (text != null) {
this.fakeEditTextForTextStateCalculation?.setText(text)
this.fakeEditTextForTextStateCalculation?.setSelection(
textSelectionStart,
textSelectionEnd
)
this.fakeEditTextForTextStateCalculation?.text?.insert(textSelectionStart, textToCommit)
success = updateTextAndSelectionForAccessibiltyNode(node)
}
} else {
if (isShowingHint) {
this.fakeEditTextForTextStateCalculation?.setText(null)
} else {
this.fakeEditTextForTextStateCalculation?.setText(text)
}
if (textSelectionStart != -1 && textSelectionEnd != -1) {
Log.d(logTag, "setting selection $textSelectionStart $textSelectionEnd")
this.fakeEditTextForTextStateCalculation?.setSelection(
textSelectionStart,
textSelectionEnd
)
}
this.fakeEditTextForTextStateCalculation?.let {
// This is essiential to make sure layout object is created. OnKeyDown may not work if layout is not created.
val rect = Rect()
node.getBoundsInScreen(rect)
it.layout(rect.left, rect.top, rect.right, rect.bottom)
it.onPreDraw()
if (event.action == KeyEventAndroid.ACTION_DOWN) {
val succ = it.onKeyDown(event.getKeyCode(), event)
Log.d(logTag, "onKeyDown $succ")
} else if (event.action == KeyEventAndroid.ACTION_UP) {
val success = it.onKeyUp(event.getKeyCode(), event)
Log.d(logTag, "keyup $success")
} else {}
}
success = updateTextAndSelectionForAccessibiltyNode(node)
}
return success
}
fun updateTextForAccessibilityNode(node: AccessibilityNodeInfo): Boolean {
var success = false
this.fakeEditTextForTextStateCalculation?.text?.let {
val arguments = Bundle()
arguments.putCharSequence(
AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE,
it.toString()
)
success = node.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, arguments)
}
return success
}
fun updateTextAndSelectionForAccessibiltyNode(node: AccessibilityNodeInfo): Boolean {
var success = updateTextForAccessibilityNode(node)
if (success) {
val selectionStart = this.fakeEditTextForTextStateCalculation?.selectionStart
val selectionEnd = this.fakeEditTextForTextStateCalculation?.selectionEnd
if (selectionStart != null && selectionEnd != null) {
val arguments = Bundle()
arguments.putInt(
AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_START_INT,
selectionStart
)
arguments.putInt(
AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_END_INT,
selectionEnd
)
success = node.performAction(AccessibilityNodeInfo.ACTION_SET_SELECTION, arguments)
Log.d(logTag, "Update selection to $selectionStart $selectionEnd success:$success")
}
}
return success
}
override fun onAccessibilityEvent(event: AccessibilityEvent) {
}
override fun onServiceConnected() {
super.onServiceConnected()
ctx = this
val info = AccessibilityServiceInfo()
if (Build.VERSION.SDK_INT >= 33) {
info.flags = FLAG_INPUT_METHOD_EDITOR or FLAG_RETRIEVE_INTERACTIVE_WINDOWS
} else {
info.flags = FLAG_RETRIEVE_INTERACTIVE_WINDOWS
}
setServiceInfo(info)
fakeEditTextForTextStateCalculation = EditText(this)
// Size here doesn't matter, we won't show this view.
fakeEditTextForTextStateCalculation?.layoutParams = LayoutParams(100, 100)
fakeEditTextForTextStateCalculation?.onPreDraw()
val layout = fakeEditTextForTextStateCalculation?.getLayout()
Log.d(logTag, "fakeEditTextForTextStateCalculation layout:$layout")
Log.d(logTag, "onServiceConnected!")
}
override fun onDestroy() {
ctx = null
super.onDestroy()
}
override fun onInterrupt() {}
}
@@ -0,0 +1,122 @@
package hbb;
import android.view.KeyEvent
import android.view.KeyCharacterMap
import hbb.MessageOuterClass.KeyboardMode
import hbb.MessageOuterClass.ControlKey
object KeyEventConverter {
fun toAndroidKeyEvent(keyEventProto: hbb.MessageOuterClass.KeyEvent): KeyEvent {
var chrValue = 0
var modifiers = 0
val keyboardMode = keyEventProto.getMode()
if (keyEventProto.hasChr()) {
if (keyboardMode == KeyboardMode.Map || keyboardMode == KeyboardMode.Translate) {
chrValue = keyEventProto.getChr()
} else {
chrValue = convertUnicodeToKeyCode(keyEventProto.getChr() as Int)
}
} else if (keyEventProto.hasControlKey()) {
chrValue = convertControlKeyToKeyCode(keyEventProto.getControlKey())
}
var modifiersList = keyEventProto.getModifiersList()
if (modifiersList != null) {
for (modifier in keyEventProto.getModifiersList()) {
val modifierValue = convertModifier(modifier)
modifiers = modifiers or modifierValue
}
}
var action = 0
if (keyEventProto.getDown() || keyEventProto.getPress()) {
action = KeyEvent.ACTION_DOWN
} else {
action = KeyEvent.ACTION_UP
}
return KeyEvent(0, 0, action, chrValue, 0, modifiers)
}
private fun convertModifier(controlKey: hbb.MessageOuterClass.ControlKey): Int {
// Add logic to map ControlKey values to Android KeyEvent key codes.
// You'll need to provide the mapping for each key.
return when (controlKey) {
ControlKey.Alt -> KeyEvent.META_ALT_ON
ControlKey.Control -> KeyEvent.META_CTRL_ON
ControlKey.CapsLock -> KeyEvent.META_CAPS_LOCK_ON
ControlKey.Meta -> KeyEvent.META_META_ON
ControlKey.NumLock -> KeyEvent.META_NUM_LOCK_ON
ControlKey.RShift -> KeyEvent.META_SHIFT_RIGHT_ON
ControlKey.Shift -> KeyEvent.META_SHIFT_ON
ControlKey.RAlt -> KeyEvent.META_ALT_RIGHT_ON
ControlKey.RControl -> KeyEvent.META_CTRL_RIGHT_ON
else -> 0 // Default to unknown.
}
}
private val tag = "KeyEventConverter"
private fun convertUnicodeToKeyCode(unicode: Int): Int {
val charMap = KeyCharacterMap.load(KeyCharacterMap.VIRTUAL_KEYBOARD)
android.util.Log.d(tag, "unicode: $unicode")
val events = charMap.getEvents(charArrayOf(unicode.toChar()))
if (events != null && events.size > 0) {
android.util.Log.d(tag, "keycode ${events[0].keyCode}")
return events[0].keyCode
}
return 0
}
private fun convertControlKeyToKeyCode(controlKey: hbb.MessageOuterClass.ControlKey): Int {
// Add logic to map ControlKey values to Android KeyEvent key codes.
// You'll need to provide the mapping for each key.
return when (controlKey) {
ControlKey.Alt -> KeyEvent.KEYCODE_ALT_LEFT
ControlKey.Backspace -> KeyEvent.KEYCODE_DEL
ControlKey.Control -> KeyEvent.KEYCODE_CTRL_LEFT
ControlKey.CapsLock -> KeyEvent.KEYCODE_CAPS_LOCK
ControlKey.Meta -> KeyEvent.KEYCODE_META_LEFT
ControlKey.NumLock -> KeyEvent.KEYCODE_NUM_LOCK
ControlKey.RShift -> KeyEvent.KEYCODE_SHIFT_RIGHT
ControlKey.Shift -> KeyEvent.KEYCODE_SHIFT_LEFT
ControlKey.RAlt -> KeyEvent.KEYCODE_ALT_RIGHT
ControlKey.RControl -> KeyEvent.KEYCODE_CTRL_RIGHT
ControlKey.DownArrow -> KeyEvent.KEYCODE_DPAD_DOWN
ControlKey.LeftArrow -> KeyEvent.KEYCODE_DPAD_LEFT
ControlKey.RightArrow -> KeyEvent.KEYCODE_DPAD_RIGHT
ControlKey.UpArrow -> KeyEvent.KEYCODE_DPAD_UP
ControlKey.End -> KeyEvent.KEYCODE_MOVE_END
ControlKey.Home -> KeyEvent.KEYCODE_MOVE_HOME
ControlKey.PageUp -> KeyEvent.KEYCODE_PAGE_UP
ControlKey.PageDown -> KeyEvent.KEYCODE_PAGE_DOWN
ControlKey.Insert -> KeyEvent.KEYCODE_INSERT
ControlKey.Escape -> KeyEvent.KEYCODE_ESCAPE
ControlKey.F1 -> KeyEvent.KEYCODE_F1
ControlKey.F2 -> KeyEvent.KEYCODE_F2
ControlKey.F3 -> KeyEvent.KEYCODE_F3
ControlKey.F4 -> KeyEvent.KEYCODE_F4
ControlKey.F5 -> KeyEvent.KEYCODE_F5
ControlKey.F6 -> KeyEvent.KEYCODE_F6
ControlKey.F7 -> KeyEvent.KEYCODE_F7
ControlKey.F8 -> KeyEvent.KEYCODE_F8
ControlKey.F9 -> KeyEvent.KEYCODE_F9
ControlKey.F10 -> KeyEvent.KEYCODE_F10
ControlKey.F11 -> KeyEvent.KEYCODE_F11
ControlKey.F12 -> KeyEvent.KEYCODE_F12
ControlKey.Space -> KeyEvent.KEYCODE_SPACE
ControlKey.Tab -> KeyEvent.KEYCODE_TAB
ControlKey.Return -> KeyEvent.KEYCODE_ENTER
ControlKey.Delete -> KeyEvent.KEYCODE_FORWARD_DEL
ControlKey.Clear -> KeyEvent.KEYCODE_CLEAR
ControlKey.Pause -> KeyEvent.KEYCODE_BREAK
ControlKey.VolumeMute -> KeyEvent.KEYCODE_VOLUME_MUTE
ControlKey.VolumeUp -> KeyEvent.KEYCODE_VOLUME_UP
ControlKey.VolumeDown -> KeyEvent.KEYCODE_VOLUME_DOWN
ControlKey.Power -> KeyEvent.KEYCODE_POWER
else -> 0 // Default to unknown.
}
}
}
@@ -0,0 +1,414 @@
package com.carriez.flutter_hbb
/**
* Handle events from flutter
* Request MediaProjection permission
*
* Inspired by [droidVNC-NG] https://github.com/bk138/droidVNC-NG
*/
import ffi.FFI
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.content.ClipboardManager
import android.os.Bundle
import android.os.Build
import android.os.IBinder
import android.util.Log
import android.view.WindowManager
import android.media.MediaCodecInfo
import android.media.MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface
import android.media.MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420SemiPlanar
import android.media.MediaCodecList
import android.media.MediaFormat
import android.util.DisplayMetrics
import androidx.annotation.RequiresApi
import org.json.JSONArray
import org.json.JSONObject
import com.hjq.permissions.XXPermissions
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import kotlin.concurrent.thread
class MainActivity : FlutterActivity() {
companion object {
var flutterMethodChannel: MethodChannel? = null
private var _rdClipboardManager: RdClipboardManager? = null
val rdClipboardManager: RdClipboardManager?
get() = _rdClipboardManager;
}
private val channelTag = "mChannel"
private val logTag = "mMainActivity"
private var mainService: MainService? = null
private var isAudioStart = false
private val audioRecordHandle = AudioRecordHandle(this, { false }, { isAudioStart })
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
if (MainService.isReady) {
Intent(activity, MainService::class.java).also {
bindService(it, serviceConnection, Context.BIND_AUTO_CREATE)
}
}
flutterMethodChannel = MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
channelTag
)
initFlutterChannel(flutterMethodChannel!!)
thread {
try {
setCodecInfo()
} catch (e: Exception) {
Log.e("MainActivity", "Failed to setCodecInfo: ${e.message}", e)
}
}
}
override fun onResume() {
super.onResume()
val inputPer = InputService.isOpen
activity.runOnUiThread {
flutterMethodChannel?.invokeMethod(
"on_state_changed",
mapOf("name" to "input", "value" to inputPer.toString())
)
}
}
private fun requestMediaProjection() {
val intent = Intent(this, PermissionRequestTransparentActivity::class.java).apply {
action = ACT_REQUEST_MEDIA_PROJECTION
}
startActivityForResult(intent, REQ_INVOKE_PERMISSION_ACTIVITY_MEDIA_PROJECTION)
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == REQ_INVOKE_PERMISSION_ACTIVITY_MEDIA_PROJECTION && resultCode == RES_FAILED) {
flutterMethodChannel?.invokeMethod("on_media_projection_canceled", null)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (_rdClipboardManager == null) {
_rdClipboardManager = RdClipboardManager(getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager)
FFI.setClipboardManager(_rdClipboardManager!!)
}
}
override fun onDestroy() {
Log.e(logTag, "onDestroy")
mainService?.let {
unbindService(serviceConnection)
}
super.onDestroy()
}
private val serviceConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
Log.d(logTag, "onServiceConnected")
val binder = service as MainService.LocalBinder
mainService = binder.getService()
}
override fun onServiceDisconnected(name: ComponentName?) {
Log.d(logTag, "onServiceDisconnected")
mainService = null
}
}
private fun initFlutterChannel(flutterMethodChannel: MethodChannel) {
flutterMethodChannel.setMethodCallHandler { call, result ->
// make sure result will be invoked, otherwise flutter will await forever
when (call.method) {
"init_service" -> {
Intent(activity, MainService::class.java).also {
bindService(it, serviceConnection, Context.BIND_AUTO_CREATE)
}
if (MainService.isReady) {
result.success(false)
return@setMethodCallHandler
}
requestMediaProjection()
result.success(true)
}
"start_capture" -> {
mainService?.let {
result.success(it.startCapture())
} ?: let {
result.success(false)
}
}
"stop_service" -> {
Log.d(logTag, "Stop service")
mainService?.let {
it.destroy()
result.success(true)
} ?: let {
result.success(false)
}
}
"check_permission" -> {
if (call.arguments is String) {
result.success(XXPermissions.isGranted(context, call.arguments as String))
} else {
result.success(false)
}
}
"request_permission" -> {
if (call.arguments is String) {
requestPermission(context, call.arguments as String)
result.success(true)
} else {
result.success(false)
}
}
START_ACTION -> {
if (call.arguments is String) {
startAction(context, call.arguments as String)
result.success(true)
} else {
result.success(false)
}
}
"check_video_permission" -> {
mainService?.let {
result.success(it.checkMediaPermission())
} ?: let {
result.success(false)
}
}
"check_service" -> {
Companion.flutterMethodChannel?.invokeMethod(
"on_state_changed",
mapOf("name" to "input", "value" to InputService.isOpen.toString())
)
Companion.flutterMethodChannel?.invokeMethod(
"on_state_changed",
mapOf("name" to "media", "value" to MainService.isReady.toString())
)
result.success(true)
}
"stop_input" -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
InputService.ctx?.disableSelf()
}
InputService.ctx = null
Companion.flutterMethodChannel?.invokeMethod(
"on_state_changed",
mapOf("name" to "input", "value" to InputService.isOpen.toString())
)
result.success(true)
}
"cancel_notification" -> {
if (call.arguments is Int) {
val id = call.arguments as Int
mainService?.cancelNotification(id)
} else {
result.success(true)
}
}
"enable_soft_keyboard" -> {
// https://blog.csdn.net/hanye2020/article/details/105553780
if (call.arguments as Boolean) {
window.clearFlags(WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM)
} else {
window.addFlags(WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM)
}
result.success(true)
}
"try_sync_clipboard" -> {
rdClipboardManager?.syncClipboard(true)
result.success(true)
}
GET_START_ON_BOOT_OPT -> {
val prefs = getSharedPreferences(KEY_SHARED_PREFERENCES, MODE_PRIVATE)
result.success(prefs.getBoolean(KEY_START_ON_BOOT_OPT, false))
}
SET_START_ON_BOOT_OPT -> {
if (call.arguments is Boolean) {
val prefs = getSharedPreferences(KEY_SHARED_PREFERENCES, MODE_PRIVATE)
val edit = prefs.edit()
edit.putBoolean(KEY_START_ON_BOOT_OPT, call.arguments as Boolean)
edit.apply()
result.success(true)
} else {
result.success(false)
}
}
SYNC_APP_DIR_CONFIG_PATH -> {
if (call.arguments is String) {
val prefs = getSharedPreferences(KEY_SHARED_PREFERENCES, MODE_PRIVATE)
val edit = prefs.edit()
edit.putString(KEY_APP_DIR_CONFIG_PATH, call.arguments as String)
edit.apply()
result.success(true)
} else {
result.success(false)
}
}
GET_VALUE -> {
if (call.arguments is String) {
if (call.arguments == KEY_IS_SUPPORT_VOICE_CALL) {
result.success(isSupportVoiceCall())
} else {
result.error("-1", "No such key", null)
}
} else {
result.success(null)
}
}
"on_voice_call_started" -> {
onVoiceCallStarted()
}
"on_voice_call_closed" -> {
onVoiceCallClosed()
}
else -> {
result.error("-1", "No such method", null)
}
}
}
}
private fun setCodecInfo() {
val codecList = MediaCodecList(MediaCodecList.REGULAR_CODECS)
val codecs = codecList.codecInfos
val codecArray = JSONArray()
val windowManager = getSystemService(Context.WINDOW_SERVICE) as WindowManager
val wh = getScreenSize(windowManager)
var w = wh.first
var h = wh.second
val align = 64
w = (w + align - 1) / align * align
h = (h + align - 1) / align * align
codecs.forEach { codec ->
val codecObject = JSONObject()
codecObject.put("name", codec.name)
codecObject.put("is_encoder", codec.isEncoder)
var hw: Boolean? = null;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
hw = codec.isHardwareAccelerated
} else {
// https://chromium.googlesource.com/external/webrtc/+/HEAD/sdk/android/src/java/org/webrtc/MediaCodecUtils.java#29
// https://chromium.googlesource.com/external/webrtc/+/master/sdk/android/api/org/webrtc/HardwareVideoEncoderFactory.java#229
if (listOf("OMX.google.", "OMX.SEC.", "c2.android").any { codec.name.startsWith(it, true) }) {
hw = false
} else if (listOf("c2.qti", "OMX.qcom.video", "OMX.Exynos", "OMX.hisi", "OMX.MTK", "OMX.Intel", "OMX.Nvidia").any { codec.name.startsWith(it, true) }) {
hw = true
}
}
if (hw != true) {
return@forEach
}
codecObject.put("hw", hw)
var mime_type = ""
codec.supportedTypes.forEach { type ->
if (listOf("video/avc", "video/hevc").contains(type)) { // "video/x-vnd.on2.vp8", "video/x-vnd.on2.vp9", "video/av01"
mime_type = type;
}
}
if (mime_type.isNotEmpty()) {
codecObject.put("mime_type", mime_type)
val caps = codec.getCapabilitiesForType(mime_type)
if (codec.isEncoder) {
// Encoder's max_height and max_width are interchangeable
if (!caps.videoCapabilities.isSizeSupported(w,h) && !caps.videoCapabilities.isSizeSupported(h,w)) {
return@forEach
}
}
codecObject.put("min_width", caps.videoCapabilities.supportedWidths.lower)
codecObject.put("max_width", caps.videoCapabilities.supportedWidths.upper)
codecObject.put("min_height", caps.videoCapabilities.supportedHeights.lower)
codecObject.put("max_height", caps.videoCapabilities.supportedHeights.upper)
val surface = caps.colorFormats.contains(COLOR_FormatSurface);
codecObject.put("surface", surface)
val nv12 = caps.colorFormats.contains(COLOR_FormatYUV420SemiPlanar)
codecObject.put("nv12", nv12)
if (!(nv12 || surface)) {
return@forEach
}
codecObject.put("min_bitrate", caps.videoCapabilities.bitrateRange.lower / 1000)
codecObject.put("max_bitrate", caps.videoCapabilities.bitrateRange.upper / 1000)
if (!codec.isEncoder) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
codecObject.put("low_latency", caps.isFeatureSupported(MediaCodecInfo.CodecCapabilities.FEATURE_LowLatency))
}
}
if (!codec.isEncoder) {
return@forEach
}
codecArray.put(codecObject)
}
}
val result = JSONObject()
result.put("version", Build.VERSION.SDK_INT)
result.put("w", w)
result.put("h", h)
result.put("codecs", codecArray)
FFI.setCodecInfo(result.toString())
}
private fun onVoiceCallStarted() {
var ok = false
mainService?.let {
ok = it.onVoiceCallStarted()
} ?: let {
isAudioStart = true
ok = audioRecordHandle.onVoiceCallStarted(null)
}
if (!ok) {
// Rarely happens, So we just add log and msgbox here.
Log.e(logTag, "onVoiceCallStarted fail")
flutterMethodChannel?.invokeMethod("msgbox", mapOf(
"type" to "custom-nook-nocancel-hasclose-error",
"title" to "Voice call",
"text" to "Failed to start voice call."))
} else {
Log.d(logTag, "onVoiceCallStarted success")
}
}
private fun onVoiceCallClosed() {
var ok = false
mainService?.let {
ok = it.onVoiceCallClosed()
} ?: let {
isAudioStart = false
ok = audioRecordHandle.onVoiceCallClosed(null)
}
if (!ok) {
// Rarely happens, So we just add log and msgbox here.
Log.e(logTag, "onVoiceCallClosed fail")
flutterMethodChannel?.invokeMethod("msgbox", mapOf(
"type" to "custom-nook-nocancel-hasclose-error",
"title" to "Voice call",
"text" to "Failed to stop voice call."))
} else {
Log.d(logTag, "onVoiceCallClosed success")
}
}
override fun onStop() {
super.onStop()
val disableFloatingWindow = FFI.getLocalOption("disable-floating-window") == "Y"
if (!disableFloatingWindow && MainService.isReady) {
startService(Intent(this, FloatingWindowService::class.java))
}
}
override fun onStart() {
super.onStart()
stopService(Intent(this, FloatingWindowService::class.java))
}
}
@@ -0,0 +1,17 @@
package com.carriez.flutter_hbb
import android.app.Application
import android.util.Log
import ffi.FFI
class MainApplication : Application() {
companion object {
private const val TAG = "MainApplication"
}
override fun onCreate() {
super.onCreate()
Log.d(TAG, "App start")
FFI.onAppStart(applicationContext)
}
}
@@ -0,0 +1,729 @@
package com.carriez.flutter_hbb
import ffi.FFI
/**
* Capture screen,get video and audio,send to rust.
* Dispatch notifications
*
* Inspired by [droidVNC-NG] https://github.com/bk138/droidVNC-NG
*/
import android.Manifest
import android.annotation.SuppressLint
import android.app.*
import android.app.PendingIntent.FLAG_IMMUTABLE
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.content.res.Configuration.ORIENTATION_LANDSCAPE
import android.graphics.Color
import android.graphics.PixelFormat
import android.hardware.display.DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR
import android.hardware.display.VirtualDisplay
import android.media.*
import android.media.projection.MediaProjection
import android.media.projection.MediaProjectionManager
import android.os.*
import android.util.DisplayMetrics
import android.util.Log
import android.view.Surface
import android.view.Surface.FRAME_RATE_COMPATIBILITY_DEFAULT
import android.view.WindowManager
import androidx.annotation.Keep
import androidx.annotation.RequiresApi
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import io.flutter.embedding.android.FlutterActivity
import java.util.concurrent.Executors
import kotlin.concurrent.thread
import org.json.JSONException
import org.json.JSONObject
import java.nio.ByteBuffer
import kotlin.math.max
import kotlin.math.min
const val DEFAULT_NOTIFY_TITLE = "RustDesk"
const val DEFAULT_NOTIFY_TEXT = "Service is running"
const val DEFAULT_NOTIFY_ID = 1
const val NOTIFY_ID_OFFSET = 100
const val MIME_TYPE = MediaFormat.MIMETYPE_VIDEO_VP9
// video const
const val MAX_SCREEN_SIZE = 1200
const val VIDEO_KEY_BIT_RATE = 1024_000
const val VIDEO_KEY_FRAME_RATE = 30
class MainService : Service() {
@Keep
@RequiresApi(Build.VERSION_CODES.N)
fun rustPointerInput(kind: Int, mask: Int, x: Int, y: Int) {
// turn on screen with LEFT_DOWN when screen off
if (!powerManager.isInteractive && (kind == 0 || mask == LEFT_DOWN)) {
if (wakeLock.isHeld) {
Log.d(logTag, "Turn on Screen, WakeLock release")
wakeLock.release()
}
Log.d(logTag,"Turn on Screen")
wakeLock.acquire(5000)
} else {
when (kind) {
0 -> { // touch
InputService.ctx?.onTouchInput(mask, x, y)
}
1 -> { // mouse
InputService.ctx?.onMouseInput(mask, x, y)
}
else -> {
}
}
}
}
@Keep
@RequiresApi(Build.VERSION_CODES.N)
fun rustKeyEventInput(input: ByteArray) {
InputService.ctx?.onKeyEvent(input)
}
@Keep
fun rustGetByName(name: String): String {
return when (name) {
"screen_size" -> {
JSONObject().apply {
put("width",SCREEN_INFO.width)
put("height",SCREEN_INFO.height)
put("scale",SCREEN_INFO.scale)
}.toString()
}
"is_start" -> {
isStart.toString()
}
else -> ""
}
}
@Keep
fun rustSetByName(name: String, arg1: String, arg2: String) {
when (name) {
"add_connection" -> {
try {
val jsonObject = JSONObject(arg1)
val id = jsonObject["id"] as Int
val username = jsonObject["name"] as String
val peerId = jsonObject["peer_id"] as String
val authorized = jsonObject["authorized"] as Boolean
val isFileTransfer = jsonObject["is_file_transfer"] as Boolean
val type = if (isFileTransfer) {
translate("Transfer file")
} else {
translate("Share screen")
}
if (authorized) {
if (!isFileTransfer && !isStart) {
startCapture()
}
onClientAuthorizedNotification(id, type, username, peerId)
} else {
loginRequestNotification(id, type, username, peerId)
}
} catch (e: JSONException) {
e.printStackTrace()
}
}
"update_voice_call_state" -> {
try {
val jsonObject = JSONObject(arg1)
val id = jsonObject["id"] as Int
val username = jsonObject["name"] as String
val peerId = jsonObject["peer_id"] as String
val inVoiceCall = jsonObject["in_voice_call"] as Boolean
val incomingVoiceCall = jsonObject["incoming_voice_call"] as Boolean
if (!inVoiceCall) {
if (incomingVoiceCall) {
voiceCallRequestNotification(id, "Voice Call Request", username, peerId)
} else {
if (!audioRecordHandle.switchOutVoiceCall(mediaProjection)) {
Log.e(logTag, "switchOutVoiceCall fail")
MainActivity.flutterMethodChannel?.invokeMethod("msgbox", mapOf(
"type" to "custom-nook-nocancel-hasclose-error",
"title" to "Voice call",
"text" to "Failed to switch out voice call."))
}
}
} else {
if (!audioRecordHandle.switchToVoiceCall(mediaProjection)) {
Log.e(logTag, "switchToVoiceCall fail")
MainActivity.flutterMethodChannel?.invokeMethod("msgbox", mapOf(
"type" to "custom-nook-nocancel-hasclose-error",
"title" to "Voice call",
"text" to "Failed to switch to voice call."))
}
}
} catch (e: JSONException) {
e.printStackTrace()
}
}
"stop_capture" -> {
Log.d(logTag, "from rust:stop_capture")
stopCapture()
}
"half_scale" -> {
val halfScale = arg1.toBoolean()
if (isHalfScale != halfScale) {
isHalfScale = halfScale
updateScreenInfo(resources.configuration.orientation)
}
}
else -> {
}
}
}
private var serviceLooper: Looper? = null
private var serviceHandler: Handler? = null
private val powerManager: PowerManager by lazy { applicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager }
private val wakeLock: PowerManager.WakeLock by lazy { powerManager.newWakeLock(PowerManager.ACQUIRE_CAUSES_WAKEUP or PowerManager.SCREEN_BRIGHT_WAKE_LOCK, "rustdesk:wakelock")}
companion object {
private var _isReady = false // media permission ready status
private var _isStart = false // screen capture start status
private var _isAudioStart = false // audio capture start status
val isReady: Boolean
get() = _isReady
val isStart: Boolean
get() = _isStart
val isAudioStart: Boolean
get() = _isAudioStart
}
private val logTag = "LOG_SERVICE"
private val useVP9 = false
private val binder = LocalBinder()
private var reuseVirtualDisplay = Build.VERSION.SDK_INT > 33
// video
private var mediaProjection: MediaProjection? = null
private var surface: Surface? = null
private val sendVP9Thread = Executors.newSingleThreadExecutor()
private var videoEncoder: MediaCodec? = null
private var imageReader: ImageReader? = null
private var virtualDisplay: VirtualDisplay? = null
// audio
private val audioRecordHandle = AudioRecordHandle(this, { isStart }, { isAudioStart })
// notification
private lateinit var notificationManager: NotificationManager
private lateinit var notificationChannel: String
private lateinit var notificationBuilder: NotificationCompat.Builder
override fun onCreate() {
super.onCreate()
Log.d(logTag,"MainService onCreate, sdk int:${Build.VERSION.SDK_INT} reuseVirtualDisplay:$reuseVirtualDisplay")
FFI.init(this)
HandlerThread("Service", Process.THREAD_PRIORITY_BACKGROUND).apply {
start()
serviceLooper = looper
serviceHandler = Handler(looper)
}
updateScreenInfo(resources.configuration.orientation)
initNotification()
// keep the config dir same with flutter
val prefs = applicationContext.getSharedPreferences(KEY_SHARED_PREFERENCES, FlutterActivity.MODE_PRIVATE)
val configPath = prefs.getString(KEY_APP_DIR_CONFIG_PATH, "") ?: ""
FFI.startServer(configPath, "")
createForegroundNotification()
}
override fun onDestroy() {
checkMediaPermission()
stopService(Intent(this, FloatingWindowService::class.java))
super.onDestroy()
}
private var isHalfScale: Boolean? = null;
private fun updateScreenInfo(orientation: Int) {
var w: Int
var h: Int
var dpi: Int
val windowManager = getSystemService(Context.WINDOW_SERVICE) as WindowManager
@Suppress("DEPRECATION")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val m = windowManager.maximumWindowMetrics
w = m.bounds.width()
h = m.bounds.height()
dpi = resources.configuration.densityDpi
} else {
val dm = DisplayMetrics()
windowManager.defaultDisplay.getRealMetrics(dm)
w = dm.widthPixels
h = dm.heightPixels
dpi = dm.densityDpi
}
val max = max(w,h)
val min = min(w,h)
if (orientation == ORIENTATION_LANDSCAPE) {
w = max
h = min
} else {
w = min
h = max
}
Log.d(logTag,"updateScreenInfo:w:$w,h:$h")
var scale = 1
if (w != 0 && h != 0) {
if (isHalfScale == true && (w > MAX_SCREEN_SIZE || h > MAX_SCREEN_SIZE)) {
scale = 2
w /= scale
h /= scale
dpi /= scale
}
if (SCREEN_INFO.width != w) {
SCREEN_INFO.width = w
SCREEN_INFO.height = h
SCREEN_INFO.scale = scale
SCREEN_INFO.dpi = dpi
if (isStart) {
stopCapture()
FFI.refreshScreen()
startCapture()
} else {
FFI.refreshScreen()
}
}
}
}
override fun onBind(intent: Intent): IBinder {
Log.d(logTag, "service onBind")
return binder
}
inner class LocalBinder : Binder() {
init {
Log.d(logTag, "LocalBinder init")
}
fun getService(): MainService = this@MainService
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
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()
}
Log.d(logTag, "service starting: ${startId}:${Thread.currentThread()}")
val mediaProjectionManager =
getSystemService(MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
intent.getParcelableExtra<Intent>(EXT_MEDIA_PROJECTION_RES_INTENT)?.let {
mediaProjection =
mediaProjectionManager.getMediaProjection(Activity.RESULT_OK, it)
checkMediaPermission()
_isReady = true
} ?: let {
Log.d(logTag, "getParcelableExtra intent null, invoke requestMediaProjection")
requestMediaProjection()
}
}
return START_NOT_STICKY // don't use sticky (auto restart), the new service (from auto restart) will lose control
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
updateScreenInfo(newConfig.orientation)
}
private fun requestMediaProjection() {
val intent = Intent(this, PermissionRequestTransparentActivity::class.java).apply {
action = ACT_REQUEST_MEDIA_PROJECTION
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
startActivity(intent)
}
@SuppressLint("WrongConstant")
private fun createSurface(): Surface? {
return if (useVP9) {
// TODO
null
} else {
Log.d(logTag, "ImageReader.newInstance:INFO:$SCREEN_INFO")
imageReader =
ImageReader.newInstance(
SCREEN_INFO.width,
SCREEN_INFO.height,
PixelFormat.RGBA_8888,
4
).apply {
setOnImageAvailableListener({ imageReader: ImageReader ->
try {
// If not call acquireLatestImage, listener will not be called again
imageReader.acquireLatestImage().use { image ->
if (image == null || !isStart) return@setOnImageAvailableListener
val planes = image.planes
val buffer = planes[0].buffer
buffer.rewind()
FFI.onVideoFrameUpdate(buffer)
}
} catch (ignored: java.lang.Exception) {
}
}, serviceHandler)
}
Log.d(logTag, "ImageReader.setOnImageAvailableListener done")
imageReader?.surface
}
}
fun onVoiceCallStarted(): Boolean {
return audioRecordHandle.onVoiceCallStarted(mediaProjection)
}
fun onVoiceCallClosed(): Boolean {
return audioRecordHandle.onVoiceCallClosed(mediaProjection)
}
fun startCapture(): Boolean {
if (isStart) {
return true
}
if (mediaProjection == null) {
Log.w(logTag, "startCapture fail,mediaProjection is null")
return false
}
updateScreenInfo(resources.configuration.orientation)
Log.d(logTag, "Start Capture")
surface = createSurface()
if (useVP9) {
startVP9VideoRecorder(mediaProjection!!)
} else {
startRawVideoRecorder(mediaProjection!!)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
if (!audioRecordHandle.createAudioRecorder(false, mediaProjection)) {
Log.d(logTag, "createAudioRecorder fail")
} else {
Log.d(logTag, "audio recorder start")
audioRecordHandle.startAudioRecorder()
}
}
checkMediaPermission()
_isStart = true
FFI.setFrameRawEnable("video",true)
MainActivity.rdClipboardManager?.setCaptureStarted(_isStart)
return true
}
@Synchronized
fun stopCapture() {
Log.d(logTag, "Stop Capture")
FFI.setFrameRawEnable("video",false)
_isStart = false
MainActivity.rdClipboardManager?.setCaptureStarted(_isStart)
// release video
if (reuseVirtualDisplay) {
// The virtual display video projection can be paused by calling `setSurface(null)`.
// https://developer.android.com/reference/android/hardware/display/VirtualDisplay.Callback
// https://learn.microsoft.com/en-us/dotnet/api/android.hardware.display.virtualdisplay.callback.onpaused?view=net-android-34.0
virtualDisplay?.setSurface(null)
} else {
virtualDisplay?.release()
}
// suface needs to be release after `imageReader.close()` to imageReader access released surface
// https://github.com/rustdesk/rustdesk/issues/4118#issuecomment-1515666629
imageReader?.close()
imageReader = null
videoEncoder?.let {
it.signalEndOfInputStream()
it.stop()
it.release()
}
if (!reuseVirtualDisplay) {
virtualDisplay = null
}
videoEncoder = null
// suface needs to be release after `imageReader.close()` to imageReader access released surface
// https://github.com/rustdesk/rustdesk/issues/4118#issuecomment-1515666629
surface?.release()
// release audio
_isAudioStart = false
audioRecordHandle.tryReleaseAudio()
}
fun destroy() {
Log.d(logTag, "destroy service")
_isReady = false
_isAudioStart = false
stopCapture()
if (reuseVirtualDisplay) {
virtualDisplay?.release()
virtualDisplay = null
}
mediaProjection = null
checkMediaPermission()
stopForeground(true)
stopService(Intent(this, FloatingWindowService::class.java))
stopSelf()
}
fun checkMediaPermission(): Boolean {
Handler(Looper.getMainLooper()).post {
MainActivity.flutterMethodChannel?.invokeMethod(
"on_state_changed",
mapOf("name" to "media", "value" to isReady.toString())
)
}
Handler(Looper.getMainLooper()).post {
MainActivity.flutterMethodChannel?.invokeMethod(
"on_state_changed",
mapOf("name" to "input", "value" to InputService.isOpen.toString())
)
}
return isReady
}
private fun startRawVideoRecorder(mp: MediaProjection) {
Log.d(logTag, "startRawVideoRecorder,screen info:$SCREEN_INFO")
if (surface == null) {
Log.d(logTag, "startRawVideoRecorder failed,surface is null")
return
}
createOrSetVirtualDisplay(mp, surface!!)
}
private fun startVP9VideoRecorder(mp: MediaProjection) {
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!!)
}
}
// 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(
"RustDeskVD",
SCREEN_INFO.width, SCREEN_INFO.height, SCREEN_INFO.dpi, VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
s, null, null
)
}
} 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()
}
}
private val cb: MediaCodec.Callback = object : MediaCodec.Callback() {
override fun onInputBufferAvailable(codec: MediaCodec, index: Int) {}
override fun onOutputFormatChanged(codec: MediaCodec, format: MediaFormat) {}
override fun onOutputBufferAvailable(
codec: MediaCodec,
index: Int,
info: MediaCodec.BufferInfo
) {
codec.getOutputBuffer(index)?.let { buf ->
sendVP9Thread.execute {
val byteArray = ByteArray(buf.limit())
buf.get(byteArray)
// sendVp9(byteArray)
codec.releaseOutputBuffer(index, false)
}
}
}
override fun onError(codec: MediaCodec, e: MediaCodec.CodecException) {
Log.e(logTag, "MediaCodec.Callback error:$e")
}
}
private fun createMediaCodec() {
Log.d(logTag, "MediaFormat.MIMETYPE_VIDEO_VP9 :$MIME_TYPE")
videoEncoder = MediaCodec.createEncoderByType(MIME_TYPE)
val mFormat =
MediaFormat.createVideoFormat(MIME_TYPE, SCREEN_INFO.width, SCREEN_INFO.height)
mFormat.setInteger(MediaFormat.KEY_BIT_RATE, VIDEO_KEY_BIT_RATE)
mFormat.setInteger(MediaFormat.KEY_FRAME_RATE, VIDEO_KEY_FRAME_RATE)
mFormat.setInteger(
MediaFormat.KEY_COLOR_FORMAT,
MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Flexible
)
mFormat.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 5)
try {
videoEncoder!!.configure(mFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
} catch (e: Exception) {
Log.e(logTag, "mEncoder.configure fail!")
}
}
private fun initNotification() {
notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationChannel = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channelId = "RustDesk"
val channelName = "RustDesk Service"
val channel = NotificationChannel(
channelId,
channelName, NotificationManager.IMPORTANCE_HIGH
).apply {
description = "RustDesk Service Channel"
}
channel.lightColor = Color.BLUE
channel.lockscreenVisibility = Notification.VISIBILITY_PRIVATE
notificationManager.createNotificationChannel(channel)
channelId
} else {
""
}
notificationBuilder = NotificationCompat.Builder(this, notificationChannel)
}
@SuppressLint("UnspecifiedImmutableFlag")
private fun createForegroundNotification() {
val intent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED
action = Intent.ACTION_MAIN
addCategory(Intent.CATEGORY_LAUNCHER)
putExtra("type", type)
}
val pendingIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
PendingIntent.getActivity(this, 0, intent, FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE)
} else {
PendingIntent.getActivity(this, 0, intent, FLAG_UPDATE_CURRENT)
}
val notification = notificationBuilder
.setOngoing(true)
.setSmallIcon(R.mipmap.ic_stat_logo)
.setDefaults(Notification.DEFAULT_ALL)
.setAutoCancel(true)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setContentTitle(DEFAULT_NOTIFY_TITLE)
.setContentText(translate(DEFAULT_NOTIFY_TEXT))
.setOnlyAlertOnce(true)
.setContentIntent(pendingIntent)
.setColor(ContextCompat.getColor(this, R.color.primary))
.setWhen(System.currentTimeMillis())
.build()
startForeground(DEFAULT_NOTIFY_ID, notification)
}
private fun loginRequestNotification(
clientID: Int,
type: String,
username: String,
peerId: String
) {
val notification = notificationBuilder
.setOngoing(false)
.setPriority(NotificationCompat.PRIORITY_MAX)
.setContentTitle(translate("Do you accept?"))
.setContentText("$type:$username-$peerId")
// .setStyle(MediaStyle().setShowActionsInCompactView(0, 1))
// .addAction(R.drawable.check_blue, "check", genLoginRequestPendingIntent(true))
// .addAction(R.drawable.close_red, "close", genLoginRequestPendingIntent(false))
.build()
notificationManager.notify(getClientNotifyID(clientID), notification)
}
private fun onClientAuthorizedNotification(
clientID: Int,
type: String,
username: String,
peerId: String
) {
cancelNotification(clientID)
val notification = notificationBuilder
.setOngoing(false)
.setPriority(NotificationCompat.PRIORITY_MAX)
.setContentTitle("$type ${translate("Established")}")
.setContentText("$username - $peerId")
.build()
notificationManager.notify(getClientNotifyID(clientID), notification)
}
private fun voiceCallRequestNotification(
clientID: Int,
type: String,
username: String,
peerId: String
) {
val notification = notificationBuilder
.setOngoing(false)
.setPriority(NotificationCompat.PRIORITY_MAX)
.setContentTitle(translate("Do you accept?"))
.setContentText("$type:$username-$peerId")
.build()
notificationManager.notify(getClientNotifyID(clientID), notification)
}
private fun getClientNotifyID(clientID: Int): Int {
return clientID + NOTIFY_ID_OFFSET
}
fun cancelNotification(clientID: Int) {
notificationManager.cancel(getClientNotifyID(clientID))
}
@SuppressLint("UnspecifiedImmutableFlag")
private fun genLoginRequestPendingIntent(res: Boolean): PendingIntent {
val intent = Intent(this, MainService::class.java).apply {
action = ACT_LOGIN_REQ_NOTIFY
putExtra(EXT_LOGIN_REQ_NOTIFY, res)
}
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
PendingIntent.getService(this, 111, intent, FLAG_IMMUTABLE)
} else {
PendingIntent.getService(this, 111, intent, FLAG_UPDATE_CURRENT)
}
}
private fun setTextNotification(_title: String?, _text: String?) {
val title = _title ?: DEFAULT_NOTIFY_TITLE
val text = _text ?: translate(DEFAULT_NOTIFY_TEXT)
val notification = notificationBuilder
.clearActions()
.setStyle(null)
.setContentTitle(title)
.setContentText(text)
.build()
notificationManager.notify(DEFAULT_NOTIFY_ID, notification)
}
}
@@ -0,0 +1,54 @@
package com.carriez.flutter_hbb
import android.app.Activity
import android.content.Intent
import android.media.projection.MediaProjectionManager
import android.os.Build
import android.os.Bundle
import android.util.Log
class PermissionRequestTransparentActivity: Activity() {
private val logTag = "permissionRequest"
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Log.d(logTag, "onCreate PermissionRequestTransparentActivity: intent.action: ${intent.action}")
when (intent.action) {
ACT_REQUEST_MEDIA_PROJECTION -> {
val mediaProjectionManager =
getSystemService(MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
val intent = mediaProjectionManager.createScreenCaptureIntent()
startActivityForResult(intent, REQ_REQUEST_MEDIA_PROJECTION)
}
else -> finish()
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == REQ_REQUEST_MEDIA_PROJECTION) {
if (resultCode == RESULT_OK && data != null) {
launchService(data)
} else {
setResult(RES_FAILED)
}
}
finish()
}
private fun launchService(mediaProjectionResultIntent: Intent) {
Log.d(logTag, "Launch MainService")
val serviceIntent = Intent(this, MainService::class.java)
serviceIntent.action = ACT_INIT_MEDIA_PROJECTION_AND_SERVICE
serviceIntent.putExtra(EXT_MEDIA_PROJECTION_RES_INTENT, mediaProjectionResultIntent)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(serviceIntent)
} else {
startService(serviceIntent)
}
}
}
@@ -0,0 +1,197 @@
package com.carriez.flutter_hbb
import java.nio.ByteBuffer
import java.util.Timer
import java.util.TimerTask
import android.content.ClipData
import android.content.ClipDescription
import android.content.ClipboardManager
import android.util.Log
import androidx.annotation.Keep
import hbb.MessageOuterClass.ClipboardFormat
import hbb.MessageOuterClass.Clipboard
import hbb.MessageOuterClass.MultiClipboards
import ffi.FFI
class RdClipboardManager(private val clipboardManager: ClipboardManager) {
private val logTag = "RdClipboardManager"
private val supportedMimeTypes = arrayOf(
ClipDescription.MIMETYPE_TEXT_PLAIN,
ClipDescription.MIMETYPE_TEXT_HTML
)
// 1. Avoid listening to the same clipboard data updated by `rustUpdateClipboard`.
// 2. Avoid sending the clipboard data before enabling client clipboard.
// 1) Disable clipboard
// 2) Copy text "a"
// 3) Enable clipboard
// 4) Switch to another app
// 5) Switch back to the app
// 6) "a" should not be sent to the client, because it's copied before enabling clipboard
//
// It's okay to that `rustEnableClientClipboard(false)` is called after `rustUpdateClipboard`,
// though the `lastUpdatedClipData` will be set to null once.
private var lastUpdatedClipData: ClipData? = null
private var isClientEnabled = true;
private var _isCaptureStarted = false;
val isCaptureStarted: Boolean
get() = _isCaptureStarted
fun checkPrimaryClip(isClient: Boolean) {
val clipData = clipboardManager.primaryClip
if (clipData != null && clipData.itemCount > 0) {
// Only handle the first item in the clipboard for now.
val clip = clipData.getItemAt(0)
// Ignore the `isClipboardDataEqual()` check if it's a host operation.
// Because it's an action manually triggered by the user.
if (isClient) {
if (lastUpdatedClipData != null && isClipboardDataEqual(clipData, lastUpdatedClipData!!)) {
Log.d(logTag, "Clipboard data is the same as last update, ignore")
return
}
}
val mimeTypeCount = clipData.description.getMimeTypeCount()
val mimeTypes = mutableListOf<String>()
for (i in 0 until mimeTypeCount) {
mimeTypes.add(clipData.description.getMimeType(i))
}
var text: CharSequence? = null;
var html: String? = null;
if (isSupportedMimeType(ClipDescription.MIMETYPE_TEXT_PLAIN)) {
text = clip?.text
}
if (isSupportedMimeType(ClipDescription.MIMETYPE_TEXT_HTML)) {
text = clip?.text
html = clip?.htmlText
}
var count = 0
val clips = MultiClipboards.newBuilder()
if (text != null) {
val content = com.google.protobuf.ByteString.copyFromUtf8(text.toString())
clips.addClipboards(Clipboard.newBuilder().setFormat(ClipboardFormat.Text).setContent(content).build())
count++
}
if (html != null) {
val content = com.google.protobuf.ByteString.copyFromUtf8(html)
clips.addClipboards(Clipboard.newBuilder().setFormat(ClipboardFormat.Html).setContent(content).build())
count++
}
if (count > 0) {
val clipsBytes = clips.build().toByteArray()
val isClientFlag = if (isClient) 1 else 0
val clipsBuf = ByteBuffer.allocateDirect(clipsBytes.size + 1).apply {
put(isClientFlag.toByte())
put(clipsBytes)
}
clipsBuf.flip()
lastUpdatedClipData = clipData
Log.d(logTag, "${if (isClient) "client" else "host"}, send clipboard data to the remote")
FFI.onClipboardUpdate(clipsBuf)
}
}
}
private fun isSupportedMimeType(mimeType: String): Boolean {
return supportedMimeTypes.contains(mimeType)
}
private fun isClipboardDataEqual(left: ClipData, right: ClipData): Boolean {
if (left.description.getMimeTypeCount() != right.description.getMimeTypeCount()) {
return false
}
val mimeTypeCount = left.description.getMimeTypeCount()
for (i in 0 until mimeTypeCount) {
if (left.description.getMimeType(i) != right.description.getMimeType(i)) {
return false
}
}
if (left.itemCount != right.itemCount) {
return false
}
for (i in 0 until left.itemCount) {
val mimeType = left.description.getMimeType(i)
if (!isSupportedMimeType(mimeType)) {
continue
}
val leftItem = left.getItemAt(i)
val rightItem = right.getItemAt(i)
if (mimeType == ClipDescription.MIMETYPE_TEXT_PLAIN || mimeType == ClipDescription.MIMETYPE_TEXT_HTML) {
if (leftItem.text != rightItem.text || leftItem.htmlText != rightItem.htmlText) {
return false
}
}
}
return true
}
fun setCaptureStarted(started: Boolean) {
_isCaptureStarted = started
}
@Keep
fun rustEnableClientClipboard(enable: Boolean) {
Log.d(logTag, "rustEnableClientClipboard: enable: $enable")
isClientEnabled = enable
lastUpdatedClipData = null
}
fun syncClipboard(isClient: Boolean) {
Log.d(logTag, "syncClipboard: isClient: $isClient, isClientEnabled: $isClientEnabled")
if (isClient && !isClientEnabled) {
return
}
checkPrimaryClip(isClient)
}
@Keep
fun rustUpdateClipboard(clips: ByteArray) {
val clips = MultiClipboards.parseFrom(clips)
var mimeTypes = mutableListOf<String>()
var text: String? = null
var html: String? = null
for (clip in clips.getClipboardsList()) {
when (clip.format) {
ClipboardFormat.Text -> {
mimeTypes.add(ClipDescription.MIMETYPE_TEXT_PLAIN)
text = String(clip.content.toByteArray(), Charsets.UTF_8)
}
ClipboardFormat.Html -> {
mimeTypes.add(ClipDescription.MIMETYPE_TEXT_HTML)
html = String(clip.content.toByteArray(), Charsets.UTF_8)
}
ClipboardFormat.ImageRgba -> {
}
ClipboardFormat.ImagePng -> {
}
else -> {
Log.e(logTag, "Unsupported clipboard format: ${clip.format}")
}
}
}
val clipDescription = ClipDescription("clipboard", mimeTypes.toTypedArray())
var item: ClipData.Item? = null
if (text == null) {
Log.e(logTag, "No text content in clipboard")
return
} else {
if (html == null) {
item = ClipData.Item(text)
} else {
item = ClipData.Item(text, html)
}
}
if (item == null) {
Log.e(logTag, "No item in clipboard")
return
}
val clipData = ClipData(clipDescription, item)
lastUpdatedClipData = clipData
clipboardManager.setPrimaryClip(clipData)
}
}
@@ -0,0 +1,78 @@
package com.carriez.flutter_hbb
// Inspired by https://github.com/yosemiteyss/flutter_volume_controller/blob/main/android/src/main/kotlin/com/yosemiteyss/flutter_volume_controller/VolumeController.kt
import android.media.AudioManager
import android.os.Build
import android.util.Log
class VolumeController(private val audioManager: AudioManager) {
private val logTag = "volume controller"
fun getVolume(streamType: Int): Double {
val current = audioManager.getStreamVolume(streamType)
val max = audioManager.getStreamMaxVolume(streamType)
return current.toDouble() / max
}
fun setVolume(volume: Double, showSystemUI: Boolean, streamType: Int) {
val max = audioManager.getStreamMaxVolume(streamType)
audioManager.setStreamVolume(
streamType,
(max * volume).toInt(),
if (showSystemUI) AudioManager.FLAG_SHOW_UI else 0
)
}
fun raiseVolume(step: Double?, showSystemUI: Boolean, streamType: Int) {
if (step == null) {
audioManager.adjustStreamVolume(
streamType,
AudioManager.ADJUST_RAISE,
if (showSystemUI) AudioManager.FLAG_SHOW_UI else 0
)
} else {
val target = getVolume(streamType) + step
setVolume(target, showSystemUI, streamType)
}
}
fun lowerVolume(step: Double?, showSystemUI: Boolean, streamType: Int) {
if (step == null) {
audioManager.adjustStreamVolume(
streamType,
AudioManager.ADJUST_LOWER,
if (showSystemUI) AudioManager.FLAG_SHOW_UI else 0
)
} else {
val target = getVolume(streamType) - step
setVolume(target, showSystemUI, streamType)
}
}
fun getMute(streamType: Int): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
audioManager.isStreamMute(streamType)
} else {
audioManager.getStreamVolume(streamType) == 0
}
}
private fun setMute(isMuted: Boolean, showSystemUI: Boolean, streamType: Int) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
audioManager.adjustStreamVolume(
streamType,
if (isMuted) AudioManager.ADJUST_MUTE else AudioManager.ADJUST_UNMUTE,
if (showSystemUI) AudioManager.FLAG_SHOW_UI else 0
)
} else {
audioManager.setStreamMute(streamType, isMuted)
}
}
fun toggleMute(showSystemUI: Boolean, streamType: Int) {
val isMuted = getMute(streamType)
setMute(!isMuted, showSystemUI, streamType)
}
}
@@ -0,0 +1,157 @@
package com.carriez.flutter_hbb
import android.Manifest.permission.*
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.media.AudioRecord
import android.media.AudioRecord.READ_BLOCKING
import android.media.MediaCodecList
import android.media.MediaFormat
import android.net.Uri
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.os.PowerManager
import android.provider.Settings
import android.provider.Settings.*
import android.util.DisplayMetrics
import android.util.Log
import android.view.WindowManager
import androidx.annotation.RequiresApi
import androidx.core.content.ContextCompat.getSystemService
import com.hjq.permissions.Permission
import com.hjq.permissions.XXPermissions
import ffi.FFI
import java.nio.ByteBuffer
import java.util.*
// intent action, extra
const val ACT_REQUEST_MEDIA_PROJECTION = "REQUEST_MEDIA_PROJECTION"
const val ACT_INIT_MEDIA_PROJECTION_AND_SERVICE = "INIT_MEDIA_PROJECTION_AND_SERVICE"
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_LOGIN_REQ_NOTIFY = "LOGIN_REQ_NOTIFY"
// Activity requestCode
const val REQ_INVOKE_PERMISSION_ACTIVITY_MEDIA_PROJECTION = 101
const val REQ_REQUEST_MEDIA_PROJECTION = 201
// Activity responseCode
const val RES_FAILED = -100
// Flutter channel
const val START_ACTION = "start_action"
const val GET_START_ON_BOOT_OPT = "get_start_on_boot_opt"
const val SET_START_ON_BOOT_OPT = "set_start_on_boot_opt"
const val SYNC_APP_DIR_CONFIG_PATH = "sync_app_dir"
const val GET_VALUE = "get_value"
const val KEY_IS_SUPPORT_VOICE_CALL = "KEY_IS_SUPPORT_VOICE_CALL"
const val KEY_SHARED_PREFERENCES = "KEY_SHARED_PREFERENCES"
const val KEY_START_ON_BOOT_OPT = "KEY_START_ON_BOOT_OPT"
const val KEY_APP_DIR_CONFIG_PATH = "KEY_APP_DIR_CONFIG_PATH"
@SuppressLint("ConstantLocale")
val LOCAL_NAME = Locale.getDefault().toString()
val SCREEN_INFO = Info(0, 0, 1, 200)
data class Info(
var width: Int, var height: Int, var scale: Int, var dpi: Int
)
fun isSupportVoiceCall(): Boolean {
// https://developer.android.com/reference/android/media/MediaRecorder.AudioSource#VOICE_COMMUNICATION
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
}
fun requestPermission(context: Context, type: String) {
XXPermissions.with(context)
.permission(type)
.request { _, all ->
if (all) {
Handler(Looper.getMainLooper()).post {
MainActivity.flutterMethodChannel?.invokeMethod(
"on_android_permission_result",
mapOf("type" to type, "result" to all)
)
}
}
}
}
fun startAction(context: Context, action: String) {
try {
context.startActivity(Intent(action).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
// don't pass package name when launch ACTION_ACCESSIBILITY_SETTINGS
if (ACTION_ACCESSIBILITY_SETTINGS != action) {
data = Uri.parse("package:" + context.packageName)
}
})
} catch (e: Exception) {
e.printStackTrace()
}
}
class AudioReader(val bufSize: Int, private val maxFrames: Int) {
private var currentPos = 0
private val bufferPool: Array<ByteBuffer>
init {
if (maxFrames < 0 || maxFrames > 32) {
throw Exception("Out of bounds")
}
if (bufSize <= 0) {
throw Exception("Wrong bufSize")
}
bufferPool = Array(maxFrames) {
ByteBuffer.allocateDirect(bufSize)
}
}
private fun next() {
currentPos++
if (currentPos >= maxFrames) {
currentPos = 0
}
}
@RequiresApi(Build.VERSION_CODES.M)
fun readSync(audioRecord: AudioRecord): ByteBuffer? {
val buffer = bufferPool[currentPos]
val res = audioRecord.read(buffer, bufSize, READ_BLOCKING)
return if (res > 0) {
next()
buffer
} else {
null
}
}
}
fun getScreenSize(windowManager: WindowManager) : Pair<Int, Int>{
var w = 0
var h = 0
@Suppress("DEPRECATION")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val m = windowManager.maximumWindowMetrics
w = m.bounds.width()
h = m.bounds.height()
} else {
val dm = DisplayMetrics()
windowManager.defaultDisplay.getRealMetrics(dm)
w = dm.widthPixels
h = dm.heightPixels
}
return Pair(w, h)
}
fun translate(input: String): String {
Log.d("common", "translate:$LOCAL_NAME")
return FFI.translateLocale(LOCAL_NAME, input)
}
@@ -0,0 +1,30 @@
// ffi.kt
package ffi
import android.content.Context
import java.nio.ByteBuffer
import com.carriez.flutter_hbb.RdClipboardManager
object FFI {
init {
System.loadLibrary("rustdesk")
}
external fun init(ctx: Context)
external fun onAppStart(ctx: Context)
external fun setClipboardManager(clipboardManager: RdClipboardManager)
external fun startServer(app_dir: String, custom_client_config: String)
external fun startService()
external fun onVideoFrameUpdate(buf: ByteBuffer)
external fun onAudioFrameUpdate(buf: ByteBuffer)
external fun translateLocale(localeName: String, input: String): String
external fun refreshScreen()
external fun setFrameRawEnable(name: String, value: Boolean)
external fun setCodecInfo(info: String)
external fun getLocalOption(key: String): String
external fun getBuildinOption(key: String): String
external fun onClipboardUpdate(clips: ByteBuffer)
external fun isServiceClipboardEnabled(): Boolean
}
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
@@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#0071FF"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M9,16.17L4.83,12l-1.42,1.41L9,19 21,7l-1.41,-1.41z"/>
</vector>
@@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#D74E4E"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M19,6.41L17.59,5 12,10.59 6.41,5 5,6.41 10.59,12 5,17.59 6.41,19 12,13.41 17.59,19 19,17.59 13.41,12z"/>
</vector>
@@ -0,0 +1,7 @@
<vector xmlns:aapt="http://schemas.android.com/aapt" xmlns:android="http://schemas.android.com/apk/res/android" android:height="320dp" android:viewportHeight="32" android:viewportWidth="32" android:width="320dp">
<path android:fillColor="#ffffff" android:pathData="M16,0L16,0A16,16 0,0 1,32 16L32,16A16,16 0,0 1,16 32L16,32A16,16 0,0 1,0 16L0,16A16,16 0,0 1,16 0z" android:strokeColor="#00000000" android:strokeWidth="1"/>
<path android:fillColor="#1a1a1a" android:pathData="m23.89,10.135 l-1.807,1.795c-0.318,0.285 -0.472,0.744 -0.293,1.131 1.204,2.518 0.747,5.52 -1.228,7.494 -1.976,1.973 -4.981,2.429 -7.502,1.226 -0.371,-0.166 -0.807,-0.025 -1.093,0.265l-1.836,1.833c-0.216,0.211 -0.322,0.51 -0.288,0.809 0.034,0.3 0.206,0.567 0.463,0.723 4.326,2.618 9.882,1.951 13.463,-1.618 3.581,-3.568 4.264,-9.115 1.655,-13.443 -0.15,-0.263 -0.414,-0.442 -0.714,-0.484 -0.3,-0.043 -0.603,0.058 -0.819,0.269zM8.265,8.184c-3.599,3.554 -4.304,9.103 -1.709,13.441 0.15,0.264 0.413,0.443 0.714,0.485 0.3,0.042 0.603,-0.058 0.82,-0.27l1.797,-1.785c0.325,-0.285 0.484,-0.749 0.303,-1.141 -1.204,-2.518 -0.748,-5.52 1.228,-7.493 1.975,-1.973 4.981,-2.429 7.502,-1.227 0.367,0.165 0.797,0.028 1.084,-0.254l1.846,-1.844c0.216,-0.211 0.322,-0.509 0.288,-0.809 -0.035,-0.299 -0.206,-0.566 -0.463,-0.723 -4.334,-2.596 -9.881,-1.908 -13.448,1.668z" android:strokeWidth="0.987992"/>
</vector>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
Flutter draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="primary">#FF0071FF</color>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#ffffff</color>
</resources>
@@ -0,0 +1,4 @@
<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>
</resources>
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
Flutter draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
<style name="Transparent" parent="Theme.AppCompat.NoActionBar">
<item name="android:windowIsTranslucent">true</item>
<item name="android:windowBackground">@android:color/transparent</item>
<item name="android:windowContentOverlay">@null</item>
<item name="android:windowNoTitle">true</item>
<item name="android:windowIsFloating">true</item>
<item name="android:backgroundDimEnabled">false</item>
</style>
</resources>
@@ -0,0 +1,7 @@
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
android:accessibilityEventTypes="typeWindowsChanged"
android:canRetrieveWindowContent="true"
android:accessibilityFlags="flagDefault"
android:notificationTimeout="50"
android:description="@string/accessibility_service_description"
android:canPerformGestures="true"/>
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.carriez.flutter_hbb">
<!-- Flutter needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>