diff --git a/android/app/build.gradle b/android/app/build.gradle index 2eeab0b0a..9aa6a16d0 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -33,7 +33,7 @@ apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" android { compileSdkVersion 30 - + // ndkVersion '22.1.7171670' // * 仅个人使用 存在多版本NDK无法自动选择 需要使用此配置指定NDK版本 [CSF] sourceSets { main.java.srcDirs += 'src/main/kotlin' } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 567c1ebbb..e096196e9 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -28,17 +28,24 @@ android:resource="@drawable/launch_background" /> - - + + + - - - - + + + + + + + diff --git a/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainActivity.kt b/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainActivity.kt index 0f2e8d208..393e96a98 100644 --- a/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainActivity.kt +++ b/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainActivity.kt @@ -1,6 +1,106 @@ package com.carriez.flutter_hbb +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.media.projection.MediaProjectionManager +import android.os.Build +import android.os.Bundle +import android.os.PersistableBundle +import android.util.Log +import androidx.annotation.RequiresApi import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import java.nio.ByteBuffer +import kotlin.concurrent.thread -class MainActivity: FlutterActivity() { + +class MainActivity : FlutterActivity() { + private val channelTag = "mChannel" + private var mediaProjectionResultIntent: Intent? = null + private val requestCode = 1 + private val buf = ByteBuffer.allocate(16) + + init { + System.loadLibrary("rustdesk") + } + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) // 必要 否则无法正确初始化flutter + + MethodChannel( + flutterEngine.dartExecutor.binaryMessenger, + channelTag + ).setMethodCallHandler { call, result -> + when (call.method) { + "getPer" -> { + Log.d(channelTag, "event from flutter,getPer") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + getMediaProjection() + } + result.success(true) + } + "startSer" -> { + mStarService() + result.success(true) + } + "stopSer" -> { + mStopService() + result.success(true) + } + else -> {} + } + } + } + + + @RequiresApi(Build.VERSION_CODES.LOLLIPOP) + private fun getMediaProjection() { + val mMediaProjectionManager = + getSystemService(MEDIA_PROJECTION_SERVICE) as MediaProjectionManager + val mIntent = mMediaProjectionManager.createScreenCaptureIntent() + startActivityForResult(mIntent, requestCode) + } + + private fun mStarService() { + if (mediaProjectionResultIntent == null) { + Log.w(channelTag, "mediaProjectionResultIntent is null") + return + } + Log.d(channelTag, "Start a service") + val serviceIntent = Intent(this, MainService::class.java) + serviceIntent.action = START_SERVICE + serviceIntent.putExtra(EXTRA_MP_DATA, mediaProjectionResultIntent) + + // TEST api < O + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + startForegroundService(serviceIntent) + } else { + startService(serviceIntent) + } + } + + private fun mStopService() { + Log.d(channelTag, "Stop service") + val serviceIntent = Intent(this, MainService::class.java) + + serviceIntent.action = STOP_SERVICE + + // TEST api < O + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + startForegroundService(serviceIntent) + } else { + startService(serviceIntent) + } + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + if (resultCode == Activity.RESULT_OK && data != null) { + Log.d(channelTag, "got mediaProjectionResultIntent ok") + mediaProjectionResultIntent = data + } + } } diff --git a/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainService.kt b/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainService.kt new file mode 100644 index 000000000..77b2f9c9e --- /dev/null +++ b/android/app/src/main/kotlin/com/carriez/flutter_hbb/MainService.kt @@ -0,0 +1,248 @@ +package com.carriez.flutter_hbb + +import android.annotation.SuppressLint +import android.app.* +import android.content.Context +import android.content.Intent +import android.graphics.Color +import android.graphics.PixelFormat +import android.hardware.display.DisplayManager.VIRTUAL_DISPLAY_FLAG_PUBLIC +import android.media.* +import android.media.projection.MediaProjection +import android.media.projection.MediaProjectionManager +import android.os.Build +import android.os.IBinder +import android.util.Log +import android.view.Surface +import android.widget.Toast +import androidx.annotation.RequiresApi +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationCompat.PRIORITY_MIN +import java.nio.ByteBuffer +import java.util.concurrent.Executors + +const val EXTRA_MP_DATA = "mp_intent" +const val START_SERVICE = "start_service" +const val STOP_SERVICE = "stop_service" +const val MIME_TYPE = MediaFormat.MIMETYPE_VIDEO_VP9 + +// 获取手机尺寸 建立连接时发送尺寸和基础信息 +const val FIXED_WIDTH = 500 // 编码器有上限 +const val FIXED_HEIGHT = 1000 +const val M_KEY_BIT_RATE = 1024_000 +const val M_KEY_FRAME_RATE = 30 + +class MainService : Service() { + + fun rustGetRaw():ByteArray{ + return rawByteArray!! + } + + external fun init(ctx:Context) + + init { + System.loadLibrary("rustdesk") + } + + private val logTag = "LOG_SERVICE" + private var mMediaProjection: MediaProjection? = null + private var surface: Surface? = null + private val singleThread = Executors.newSingleThreadExecutor() + private var mEncoder: MediaCodec? = null + private var rawByteArray :ByteArray? = null + + override fun onBind(intent: Intent): IBinder? { + return null + } + + @RequiresApi(Build.VERSION_CODES.LOLLIPOP) + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + Log.d("whichService", "this service:${Thread.currentThread()}") + init(this) // 注册到rust + if (intent?.action == START_SERVICE) { + Log.d(logTag, "service starting:${startId}:${Thread.currentThread()}") + createNotification() + val mMediaProjectionManager = + getSystemService(MEDIA_PROJECTION_SERVICE) as MediaProjectionManager + mMediaProjection = intent.getParcelableExtra(EXTRA_MP_DATA)?.let { + mMediaProjectionManager.getMediaProjection(Activity.RESULT_OK, it) + } + Log.d(logTag, "获取mMediaProjection成功$mMediaProjection") + if (testSupport()) { + startRecorder() + } else { + Toast.makeText(this, "此设备不支持:$MIME_TYPE", Toast.LENGTH_SHORT).show() + stopSelf(startId) + } + } else if (intent?.action == STOP_SERVICE) { + mEncoder?.let { + try { + Log.d(logTag, "正在释放encoder") + it.signalEndOfInputStream() + it.stop() + it.release() + } catch (e: Exception) { + null + } + } + stopSelf() + } + return super.onStartCommand(intent, flags, startId) + } + + lateinit var mImageReader:ImageReader // * 注意 这里要成为成员变量,防止被回收 https://www.cnblogs.com/yongdaimi/p/11004560.html + @SuppressLint("WrongConstant") + @RequiresApi(Build.VERSION_CODES.LOLLIPOP) + private fun startRecorder() { + Log.d(logTag, "startRecorder") + mMediaProjection?.let { mp -> + // 使用原始数据 + mImageReader = + ImageReader.newInstance(FIXED_WIDTH, FIXED_HEIGHT, PixelFormat.RGBA_8888, 2) // 至少是2 + mImageReader.setOnImageAvailableListener({ imageReader: ImageReader -> + Log.d(logTag, "on image") + try { + imageReader.acquireLatestImage().use { image -> + if (image == null) return@setOnImageAvailableListener + val planes = image.planes + val buffer = planes[0].buffer + buffer.rewind() + // 这里注意 处理不当会引发OOM + if (rawByteArray == null){ + rawByteArray = ByteArray(buffer.capacity()) + buffer.get(rawByteArray!!) + }else{ + buffer.get(rawByteArray!!) + } + } + } catch (ignored: java.lang.Exception) { + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + imageReader.discardFreeBuffers() + } + }, null) + mp.createVirtualDisplay( + "rustdesk test", + FIXED_WIDTH, FIXED_HEIGHT, 200, VIRTUAL_DISPLAY_FLAG_PUBLIC, + mImageReader.surface, null, null + ) + + + // 使用内置编码器 +// createMediaCodec() +// mEncoder?.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() +// mp.createVirtualDisplay( +// "rustdesk test", +// FIXED_WIDTH, FIXED_HEIGHT, 200, VIRTUAL_DISPLAY_FLAG_PUBLIC, +// surface, null, null +// ) +// } + } ?: let { + Log.d(logTag, "startRecorder fail,mMediaProjection is null") + } + } + + private val cb: MediaCodec.Callback = @RequiresApi(Build.VERSION_CODES.LOLLIPOP) + 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 -> + singleThread.execute { + // TODO 优化内存使用方式 + 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") + } + } + + external fun sendRaw(buf: ByteBuffer) + external fun sendVp9(data: ByteArray) + + @RequiresApi(Build.VERSION_CODES.LOLLIPOP) + private fun testSupport(): Boolean { + val res = MediaCodecList(MediaCodecList.ALL_CODECS) + .findEncoderForFormat( + MediaFormat.createVideoFormat( + MediaFormat.MIMETYPE_VIDEO_VP9, + FIXED_WIDTH, + FIXED_HEIGHT + ) + ) + return res?.let { + true + } ?: let { + false + } + } + + private fun createMediaCodec() { + Log.d(logTag, "MediaFormat.MIMETYPE_VIDEO_VP9 :$MIME_TYPE") + mEncoder = MediaCodec.createEncoderByType(MIME_TYPE) + val mFormat = MediaFormat.createVideoFormat(MIME_TYPE, FIXED_WIDTH, FIXED_HEIGHT) + mFormat.setInteger(MediaFormat.KEY_BIT_RATE, M_KEY_BIT_RATE) + mFormat.setInteger(MediaFormat.KEY_FRAME_RATE, M_KEY_FRAME_RATE) // codec的帧率设置无效 + mFormat.setInteger( + MediaFormat.KEY_COLOR_FORMAT, + MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Flexible + ) + mFormat.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 5) + try { + mEncoder!!.configure(mFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) + } catch (e: Exception) { + Log.e(logTag, "mEncoder.configure fail!") + } + } + + private fun createNotification() { + val channelId = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + createNotificationChannel("my_service", "My Background Service") + } else { + "" + } + val notification: Notification = NotificationCompat.Builder(this, channelId) + .setOngoing(true) + .setContentTitle("Hello") + .setPriority(PRIORITY_MIN) + .setContentText("TEST TEST") + .build() + startForeground(11, notification) + } + + @RequiresApi(Build.VERSION_CODES.O) + private fun createNotificationChannel(channelId: String, channelName: String): String { + val chan = NotificationChannel( + channelId, + channelName, NotificationManager.IMPORTANCE_NONE + ) + chan.lightColor = Color.BLUE + chan.lockscreenVisibility = Notification.VISIBILITY_PRIVATE + val service = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + service.createNotificationChannel(chan) + return channelId + } + + override fun onDestroy() { + Log.d(logTag, "service stop:${Thread.currentThread()}") + Toast.makeText(this, "service done", Toast.LENGTH_SHORT).show() + } +} \ No newline at end of file diff --git a/android_doc.md b/android_doc.md new file mode 100644 index 000000000..a809c8a9d --- /dev/null +++ b/android_doc.md @@ -0,0 +1,151 @@ +# RustDesk 安卓端被控文档记录 + +### 1.获取屏幕录像 + +##### 原理 流程 +MediaProjectionManager -> MediaProjection +-> VirtualDisplay -> Surface -> MediaCodec + +- 获取mediaProjectionResultIntent + - **必须activity** + - activity获取mediaProjectionResultIntent + - 会提示用户 “获取屏幕录制权限” + +- 获取MediaProjection + - **必须service** + - 将mediaProjectionResultIntent 传递到后台服务 + - 通过后台服务获取MediaProjection + +- 创建Surface(理解为一个buf)和Surface消费者 + - MediaCodec生成Surface传入VirtualDisplay的入参中 + - 设定编码等各类参数 + +- 获取VirtualDisplay(Surface 生产者) + - 前台服务 + - MediaProjection createVirtualDisplay方法创建VirtualDisplay + - 创建VirtualDisplay的入参之一是Surface + - 需要设定正确的VirtualDisplay尺寸 + +- 获取编码后的buf + - 通过MediaCodec回调获取到可用的数据 +- 通过jni传入Rust服务 + - 直接通过jni调用rust端的函数,将数据传递给video_service中 + +- 安卓VP9兼容性待测试 + - 目前测试2017年一台安卓7机器不支持vp9硬件加速 + - **安卓内置的编解码器并不一定是硬件解码** + +##### 权限注意 +``` + + + + +``` +- API大于O(26)时需要startForegroundService,且需要正确设置通知栏, + 新特性中使用ForegroundService不会被系统杀掉 + + +##### 资料 +- 关于 FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION 权限 + https://zhuanlan.zhihu.com/p/360356420 + +- 关于Notification 和 NotificationNotification + https://stackoverflow.com/questions/47531742/startforeground-fail-after-upgrade-to-android-8-1 + https://developer.android.com/reference/android/support/v4/app/NotificationCompat.Builder.html#NotificationCompat.Builder(android.content.Context) + +// TODO 使用 NotificationCompat 的区别 + +
+ +### 2.获取控制 +暂时可行的方案是使用安卓无障碍服务 参考droidVNC项目,但droidVNC的实现并不完善,droidVNC没有实现连续触控。 + +#### 无障碍服务获取权限 +- https://developer.android.com/guide/topics/ui/accessibility/service?hl=zh-cn#manifest +- 清单文件 + ``` + + + + + + + + ``` +- 创建一个单独的xml文件,用于无障碍服务配置 + ``` + // 首先清单文件中增加文件地址 + + ... + + + // 然后在此位置添加xml + // /res/xml/accessibility_service_config.xml + + ``` +- 连续手势 https://developer.android.com/guide/topics/ui/accessibility/service?hl=zh-cn#continued-gestures + +
+ +### 其他 +- Kotlin 与 compose 版本设置问题 + - https://stackoverflow.com/questions/67600344/jetpack-compose-on-kotlin-1-5-0 + - 在根目录的gradle中 设置两个正确对应版本 + +### Rust JVM 互相调用 + +rust端 引入 jni crate +https://docs.rs/jni/0.19.0/jni/index.html + +Kotlin端 +类中通过init{} 引入lib的调用 +```kotlin +class Main{ + init{ + System.loadLibrary("$libname") + } +} +``` + +Rust端 +使用jni规则进行函数命名 +```rust +pub unsafe extern "system" fn Java_com_carriez_flutter_1hbb_MainActivity_init( + env: JNIEnv, + class: JClass, + ctx:JObject, +){ + +} +``` +- 注意,原项目包名flutter_hbb 带有下划线,通过安卓的编译提示获得的命名方式为如上。 + +- 将安卓的对象实例(Context)在init的过程中传入rust端, +context通过env.new_global_ref()变成全局引用 +env.get_java_vm()获取到jvm + - 原理上 Rust端通过类找静态方法也可行,但在kotlin端测试失败,会遇到类名找不到,类静态方法找不到等问题,目前仅使用绑定具体context对象即可。 +- 将jvm和context 固定到全局变量中等待需要时候引用 + +- 使用时,需要确保jvm与当前的线程绑定 +jvm.attach_current_thread_permanently() + +- 然后通过jvm获得env +jvm.get_env() + +- 通过env.call_method()方法传入context.as_obj()使用对象的方法 + +传递数据 +Kotlin 中的 ByteArray 类 会在JVM中编译成为java的byte[] +byte[]通过jni传递到rust端时 +通过jni.rs的方法 +env.convert_byte_array()即可转化为Vec \ No newline at end of file diff --git a/build_android.sh b/build_android.sh index b4ba09671..4f4038593 100755 --- a/build_android.sh +++ b/build_android.sh @@ -2,3 +2,6 @@ $ANDROID_NDK/toolchains/aarch64-linux-android-4.9/prebuilt/darwin-x86_64/bin/aarch64-linux-android-strip android/app/src/main/jniLibs/arm64-v8a/* flutter build apk --target-platform android-arm64 --release --obfuscate --split-debug-info ./split-debug-info flutter build appbundle --target-platform android-arm64 --release --obfuscate --split-debug-info ./split-debug-info + +# build in linux +# $ANDROID_NDK/toolchains/aarch64-linux-android-4.9/prebuilt/linux-x86_64/bin/aarch64-linux-android-strip android/app/src/main/jniLibs/arm64-v8a/* diff --git a/lib/home_page.dart b/lib/home_page.dart index 19ca26339..70f444703 100644 --- a/lib/home_page.dart +++ b/lib/home_page.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import 'package:tuple/tuple.dart'; import 'package:package_info/package_info.dart'; @@ -21,6 +22,7 @@ class HomePage extends StatefulWidget { class _HomePageState extends State { final _idController = TextEditingController(); var _updateUrl = ''; + static const toAndroidChannel = MethodChannel("mChannel"); @override void initState() { @@ -96,9 +98,24 @@ class _HomePageState extends State { fontWeight: FontWeight.bold)))), getSearchBarUI(), getPeers(), + ElevatedButton(onPressed:_toAndroidGetPer, child: Text("获取权限事件")), + ElevatedButton(onPressed:_toAndroidStartSer, child: Text("开启录屏服务")), + ElevatedButton(onPressed:_toAndroidStopSer, child: Text("停止录屏服务")) ]), )); } + Future _toAndroidGetPer() async{ + bool res = await toAndroidChannel.invokeMethod("getPer"); + debugPrint("_toAndroidGetPer:$res"); + } + Future _toAndroidStartSer() async{ + bool res = await toAndroidChannel.invokeMethod("startSer"); + debugPrint("_toAndroidStartSer:$res"); + } + Future _toAndroidStopSer() async{ + bool res = await toAndroidChannel.invokeMethod("stopSer"); + debugPrint("_toAndroidStopSer:$res"); + } void onConnect() { var id = _idController.text.trim(); diff --git a/pubspec.lock b/pubspec.lock index 7f7f91a5d..4ec2db940 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -21,7 +21,7 @@ packages: name: async url: "https://pub.dartlang.org" source: hosted - version: "2.6.1" + version: "2.8.2" boolean_selector: dependency: transitive description: @@ -35,14 +35,14 @@ packages: name: characters url: "https://pub.dartlang.org" source: hosted - version: "1.1.0" + version: "1.2.0" charcode: dependency: transitive description: name: charcode url: "https://pub.dartlang.org" source: hosted - version: "1.2.0" + version: "1.3.1" clock: dependency: transitive description: @@ -227,14 +227,14 @@ packages: name: matcher url: "https://pub.dartlang.org" source: hosted - version: "0.12.10" + version: "0.12.11" meta: dependency: transitive description: name: meta url: "https://pub.dartlang.org" source: hosted - version: "1.3.0" + version: "1.7.0" nested: dependency: transitive description: @@ -428,7 +428,7 @@ packages: name: test_api url: "https://pub.dartlang.org" source: hosted - version: "0.3.0" + version: "0.4.3" tuple: dependency: "direct main" description: @@ -491,7 +491,7 @@ packages: name: vector_math url: "https://pub.dartlang.org" source: hosted - version: "2.1.0" + version: "2.1.1" wakelock: dependency: "direct main" description: @@ -556,5 +556,5 @@ packages: source: hosted version: "3.1.0" sdks: - dart: ">=2.13.0 <3.0.0" + dart: ">=2.14.0 <3.0.0" flutter: ">=2.0.0"