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:
@@ -0,0 +1,11 @@
|
||||
gradle-wrapper.jar
|
||||
/.gradle
|
||||
/captures/
|
||||
/gradlew
|
||||
/gradlew.bat
|
||||
/local.properties
|
||||
GeneratedPluginRegistrant.java
|
||||
|
||||
# Remember to never publicly share your keystore.
|
||||
# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app
|
||||
key.properties
|
||||
@@ -0,0 +1,137 @@
|
||||
import com.google.protobuf.gradle.*
|
||||
import groovy.json.JsonSlurper
|
||||
|
||||
plugins {
|
||||
id "com.google.protobuf" version "0.9.4"
|
||||
id "com.android.application"
|
||||
id "kotlin-android"
|
||||
id "dev.flutter.flutter-gradle-plugin"
|
||||
}
|
||||
|
||||
def keystoreProperties = new Properties()
|
||||
def keystorePropertiesFile = rootProject.file('key.properties')
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
|
||||
}
|
||||
|
||||
def localProperties = new Properties()
|
||||
def localPropertiesFile = rootProject.file('local.properties')
|
||||
if (localPropertiesFile.exists()) {
|
||||
localPropertiesFile.withReader('UTF-8') { reader ->
|
||||
localProperties.load(reader)
|
||||
}
|
||||
}
|
||||
|
||||
def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
|
||||
if (flutterVersionCode == null) {
|
||||
flutterVersionCode = '1'
|
||||
}
|
||||
|
||||
def flutterVersionName = localProperties.getProperty('flutter.versionName')
|
||||
if (flutterVersionName == null) {
|
||||
flutterVersionName = '1.0'
|
||||
}
|
||||
|
||||
// Add rustls-platform-verifier Android support
|
||||
String findRustlsPlatformVerifierMavenDir() {
|
||||
def dependencyText = providers.exec {
|
||||
it.workingDir = new File("../..")
|
||||
commandLine("cargo", "metadata", "--format-version", "1")
|
||||
}.standardOutput.asText.get()
|
||||
|
||||
def dependencyJson = new JsonSlurper().parseText(dependencyText)
|
||||
def pkg = dependencyJson.packages.find { it.name == "rustls-platform-verifier-android" }
|
||||
|
||||
if (pkg == null) {
|
||||
throw new GradleException("rustls-platform-verifier-android package not found in cargo metadata!")
|
||||
}
|
||||
|
||||
def manifestPath = file(pkg.manifest_path)
|
||||
def mavenDir = new File(manifestPath.parentFile, "maven")
|
||||
|
||||
if (!mavenDir.exists()) {
|
||||
throw new GradleException("Maven directory not found at: ${mavenDir.path}")
|
||||
}
|
||||
|
||||
println("✓ Found rustls-platform-verifier maven repo at: ${mavenDir.path}")
|
||||
return mavenDir.path
|
||||
}
|
||||
|
||||
|
||||
repositories {
|
||||
maven {
|
||||
url = findRustlsPlatformVerifierMavenDir()
|
||||
metadataSources.artifact()
|
||||
}
|
||||
}
|
||||
|
||||
protobuf {
|
||||
protoc {
|
||||
artifact = 'com.google.protobuf:protoc:3.20.1'
|
||||
}
|
||||
|
||||
generateProtoTasks {
|
||||
all().configureEach { task ->
|
||||
task.builtins {
|
||||
java {
|
||||
option "lite"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdkVersion 34
|
||||
sourceSets {
|
||||
main.java.srcDirs += 'src/main/kotlin'
|
||||
|
||||
main.proto.srcDirs += '../../../libs/hbb_common/protos'
|
||||
main.proto.includes += "message.proto"
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
applicationId "com.carriez.flutter_hbb"
|
||||
minSdkVersion 22
|
||||
targetSdkVersion 33
|
||||
versionCode flutterVersionCode.toInteger()
|
||||
versionName flutterVersionName
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
release {
|
||||
keyAlias keystoreProperties['keyAlias']
|
||||
keyPassword keystoreProperties['keyPassword']
|
||||
storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null
|
||||
storePassword keystoreProperties['storePassword']
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig signingConfigs.release
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source '../..'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'com.google.protobuf:protobuf-javalite:3.20.1'
|
||||
implementation "androidx.media:media:1.6.0"
|
||||
implementation 'com.github.getActivity:XXPermissions:18.5'
|
||||
implementation("org.jetbrains.kotlin:kotlin-stdlib") { version { strictly("1.9.10") } }
|
||||
implementation 'com.caverock:androidsvg-aar:1.4'
|
||||
implementation "rustls:rustls-platform-verifier:0.1.1"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
# Keep class members from protobuf generated code.
|
||||
-keepclassmembers class * extends com.google.protobuf.GeneratedMessageLite {
|
||||
<fields>;
|
||||
}
|
||||
|
||||
# Keep rustls-platform-verifier classes for JNI
|
||||
-keep, includedescriptorclasses class org.rustls.platformverifier.** { *; }
|
||||
@@ -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() {}
|
||||
}
|
||||
+122
@@ -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)
|
||||
}
|
||||
}
|
||||
+54
@@ -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>
|
||||
@@ -0,0 +1,19 @@
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
jcenter()
|
||||
maven { url 'https://jitpack.io' }
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.buildDir = '../build'
|
||||
subprojects {
|
||||
project.buildDir = "${rootProject.buildDir}/${project.name}"
|
||||
}
|
||||
subprojects {
|
||||
project.evaluationDependsOn(':app')
|
||||
}
|
||||
|
||||
tasks.register("clean", Delete) {
|
||||
delete rootProject.buildDir
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
org.gradle.jvmargs=-Xmx1024M
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=true
|
||||
org.gradle.daemon=false
|
||||
@@ -0,0 +1,6 @@
|
||||
#Fri Jun 23 08:50:38 CEST 2017
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.4-all.zip
|
||||
@@ -0,0 +1,25 @@
|
||||
pluginManagement {
|
||||
def flutterSdkPath = {
|
||||
def properties = new Properties()
|
||||
file("local.properties").withInputStream { properties.load(it) }
|
||||
def flutterSdkPath = properties.getProperty("flutter.sdk")
|
||||
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
|
||||
return flutterSdkPath
|
||||
}()
|
||||
|
||||
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
|
||||
id "com.android.application" version "7.3.1" apply false
|
||||
id "org.jetbrains.kotlin.android" version "2.1.21" apply false
|
||||
}
|
||||
|
||||
include ":app"
|
||||
Reference in New Issue
Block a user