diff --git a/app/build.gradle b/app/build.gradle index 37cef09..7f9a046 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -2,6 +2,7 @@ plugins { id 'com.android.application' id 'org.jetbrains.kotlin.android' id 'kotlin-kapt' + id 'com.google.protobuf' } static def appName() { @@ -252,15 +253,18 @@ dependencies { implementation "androidx.room:room-rxjava3:2.8.4" kapt "androidx.room:room-compiler:2.8.4" // ViewModel和LiveData + implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.10.0' + implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.10.0' + implementation "androidx.lifecycle:lifecycle-runtime:2.10.0" implementation "androidx.lifecycle:lifecycle-viewmodel:2.10.0" implementation "androidx.lifecycle:lifecycle-livedata:2.10.0" - implementation "androidx.lifecycle:lifecycle-runtime:2.10.0" // 可选:RxJava3 Observable → LiveData 的桥接 implementation 'androidx.lifecycle:lifecycle-reactivestreams:2.10.0' kapt "androidx.lifecycle:lifecycle-compiler:2.10.0" // LifecycleService 核心库 implementation "androidx.lifecycle:lifecycle-service:2.10.0" + testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.3.0' androidTestImplementation 'androidx.test.espresso:espresso-core:3.7.0' @@ -410,6 +414,26 @@ dependencies { implementation "com.github.gzu-liyujiang.AndroidPicker:ImagePicker:$AndroidPicker.version" //阴影布局 implementation 'com.github.lihangleo2:ShadowLayout:3.4.5' + + // ---- WebRTC 远程控制(移植自 WebRTCControlled)---- + // WebRTC SDK(屏幕采集 + PeerConnection + DataChannel) + implementation 'io.github.webrtc-sdk:android:144.7559.09' + // Protobuf(DataChannel 控制指令二进制) + implementation 'com.google.protobuf:protobuf-java:3.25.1' +} + +// WebRTC 远程控制:protobuf 编译配置(control_message.proto 位于 src/main/proto/) +protobuf { + protoc { + artifact = 'com.google.protobuf:protoc:3.25.1' + } + generateProtoTasks { + all().each { task -> + task.builtins { + java {} + } + } + } } // 在 dependencies 之后添加 diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e690e49..27e23c5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -34,6 +34,12 @@ + + + + + + @@ -115,6 +121,10 @@ android:name=".activity.settings.utils.SettingsUtilsActivity" android:launchMode="singleTask" android:screenOrientation="portrait" /> + + + + + + + { - private static final String TAG = "SettingsActivity"; + private static final String TAG = "SettingsCallActivity"; @Override public boolean setfitWindow() { diff --git a/app/src/main/java/com/ttstd/dialer/activity/settings/home/SettingsActivity.java b/app/src/main/java/com/ttstd/dialer/activity/settings/home/SettingsActivity.java index fa59e0c..4958f7f 100644 --- a/app/src/main/java/com/ttstd/dialer/activity/settings/home/SettingsActivity.java +++ b/app/src/main/java/com/ttstd/dialer/activity/settings/home/SettingsActivity.java @@ -9,6 +9,7 @@ import androidx.core.content.ContextCompat; import com.ttstd.dialer.R; import com.ttstd.dialer.activity.alarm.list.AlarmListActivity; +import com.ttstd.dialer.activity.settings.assist.SettingsAssistActivity; import com.ttstd.dialer.activity.settings.call.SettingsCallActivity; import com.ttstd.dialer.activity.settings.utils.SettingsUtilsActivity; import com.ttstd.dialer.base.mvvm.BaseMvvmActivity; @@ -66,6 +67,10 @@ public class SettingsActivity extends BaseMvvmActivity { - private static final String TAG = "SettingsActivity"; - - private MMKV mMMKV = MMKV.mmkvWithID(CommonConfig.MMKV_ID, MMKV.MULTI_PROCESS_MODE); + private static final String TAG = "SettingsUtilsActivity"; @Override public boolean setfitWindow() { @@ -58,41 +54,21 @@ public class SettingsUtilsActivity extends BaseMvvmActivity - * 由后端 {@code DeviceService.mirror} 下发,推送通道已打通, - * 具体实现(如开启投屏/屏幕流推送)待补充。 + * 由后端 {@code DeviceService.mirror} 下发,推送通道已打通。 + * 收到命令后启动屏幕采集授权 Activity,经 MediaProjection 授权后 + * 启动 {@link ScreenCaptureService} 进行 WebRTC 远程屏幕串流。 */ - private void mirror() { - Logger.e(TAG, "mirror: 收到屏幕镜像推送,功能待实现"); + /** + * 屏幕镜像(contentType = 16, mirror)。 + *

+ * extra 可携带以下字段(由后端 {@code DeviceService.mirror} 下发): + * - remote_assistance_key : 是否为远程协助请求,1=是(需弹出采集界面) + * - accept_remote_assistance_key : 是否自动接受,1=自动接收并采集,0=需用户确认 + *

+ * 处理规则: + * - remote_assistance_key != 1 → 忽略(非远程协助镜像命令) + * - accept_remote_assistance_key == 1 → 自动接收并启动屏幕采集串流 + * - accept_remote_assistance_key == 0 → 弹出确认对话框,用户确认后再采集 + */ + private void mirror(String extra) { + Logger.e(TAG, "mirror: 收到屏幕镜像推送,extra=" + extra); + + int remoteKey = mMMKV.decodeInt(CommonConfig.KEY_REMOTE_ASSISTANCE_ENABLE, 0); + int acceptKey = mMMKV.decodeInt(CommonConfig.KEY_AUTO_REMOTE_ASSISTANCE_ENABLE, 0); + + // 非远程协助请求:不弹出采集界面 + if (remoteKey != 1) { + Logger.d(TAG, "mirror: remote_assistance_key != 1,忽略"); + return; + } + + try { + // 若已处于串流中,不重复启动 + if (ScreenCaptureService.getInstance() != null + && ScreenCaptureService.getInstance().isStreaming()) { + Logger.d(TAG, "mirror: 屏幕串流已在运行,忽略重复命令"); + return; + } + + if (acceptKey == 1 || isAutoRemoteAssistanceEnabled()) { + // 自动接收并采集窗口串流(推送指定自动,或本地已开启自动接收开关) + Logger.d(TAG, "mirror: 自动接收远程协助,启动采集"); + ScreenCaptureActivity.start(mContext); + } else { + // 需用户确认:弹出询问对话框 + Logger.d(TAG, "mirror: 需用户确认,弹出询问对话框"); + showRemoteAssistanceConfirmDialog(); + } + } catch (Exception e) { + Logger.e(TAG, "mirror: 启动屏幕串流失败: " + e.getMessage()); + } + } + + /** + * 远程协助询问对话框:用户确认后启动屏幕采集。 + * 在老人设备上最小化交互,仅一个"允许"按钮。 + */ + private void showRemoteAssistanceConfirmDialog() { + new Handler(Looper.getMainLooper()).post(() -> { + try { + AlertDialog dialog = new AlertDialog.Builder( + new ContextThemeWrapper(mContext, android.R.style.Theme_DeviceDefault_Light_Dialog_Alert)) + .setTitle(R.string.remote_assistance_confirm_title) + .setMessage(R.string.remote_assistance_confirm_message) + .setPositiveButton(R.string.remote_assistance_confirm_ok, + (d, which) -> ScreenCaptureActivity.start(mContext)) + .setNegativeButton(R.string.remote_assistance_confirm_cancel, + (d, which) -> d.dismiss()) + .setCancelable(false) + .create(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + dialog.getWindow().setType(WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY); + } else { + dialog.getWindow().setType(WindowManager.LayoutParams.TYPE_SYSTEM_ALERT); + } + dialog.show(); + } catch (Exception e) { + Logger.e(TAG, "mirror: 显示询问对话框失败,直接启动采集: " + e.getMessage()); + ScreenCaptureActivity.start(mContext); + } + }); + } + + /** + * 读取本地"自动接收远程协助"开关(与设置页 siAutoRemoteAssistance 同步)。 + */ + private boolean isAutoRemoteAssistanceEnabled() { + try { + MMKV mmkv = MMKV.mmkvWithID("MMKV_DEFAULT"); + return mmkv != null && mmkv.decodeBool("AutoRemoteAssistanceEnable", false); + } catch (Exception e) { + return false; + } } /** diff --git a/app/src/main/java/com/ttstd/dialer/service/main/MainService.java b/app/src/main/java/com/ttstd/dialer/service/main/MainService.java index 5e94a59..170fbc1 100644 --- a/app/src/main/java/com/ttstd/dialer/service/main/MainService.java +++ b/app/src/main/java/com/ttstd/dialer/service/main/MainService.java @@ -31,6 +31,7 @@ import com.tencent.mmkv.MMKV; import com.ttstd.dialer.BuildConfig; import com.ttstd.dialer.R; import com.ttstd.dialer.activity.main.MainActivity; +import com.ttstd.dialer.activity.settings.assist.SettingsAssistActivity; import com.ttstd.dialer.base.BaseService; import com.ttstd.dialer.bean.req.SnLocationReq; import com.ttstd.dialer.config.CommonConfig; @@ -39,6 +40,7 @@ import com.ttstd.dialer.manager.ContactSyncManager; import com.ttstd.dialer.manager.MqttManager; import com.ttstd.dialer.utils.Logger; import com.ttstd.dialer.utils.SystemUtils; +import com.ttstd.dialer.webrtc.signaling.WebSocketClient; public class MainService extends BaseService implements NetworkUtils.OnNetworkStatusChangedListener, ViewModelStoreOwner { private static final String TAG = "MainService"; @@ -127,6 +129,14 @@ public class MainService extends BaseService implements NetworkUtils.OnNetworkSt if (enableFloatWindow) { showFloatWindow(); } + boolean remote = mMMKV.decodeInt(CommonConfig.KEY_REMOTE_ASSISTANCE_ENABLE, 0) == 1; + Logger.e(TAG, "onCreate: remote = " + remote); + if (remote) { + // 开启:后台建立全局信令连接 + WebSocketClient client = WebSocketClient.getInstance(); + client.setServerUrl(CommonConfig.SIGNAL_SERVER_URL); + client.connect(); + } } diff --git a/app/src/main/java/com/ttstd/dialer/view/SettingItem.java b/app/src/main/java/com/ttstd/dialer/view/SettingItem.java index d04dd96..992b618 100644 --- a/app/src/main/java/com/ttstd/dialer/view/SettingItem.java +++ b/app/src/main/java/com/ttstd/dialer/view/SettingItem.java @@ -16,21 +16,26 @@ import androidx.constraintlayout.widget.ConstraintLayout; import com.ttstd.dialer.view.SwitchButton; import com.ttstd.dialer.R; +import com.ttstd.dialer.config.CommonConfig; +import com.tencent.mmkv.MMKV; import org.jetbrains.annotations.NotNull; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + public class SettingItem extends ConstraintLayout { private OnClickListener mRootOnClickListener; private SwitchButton.OnCheckedChangeListener mOnToggleChanged; private static final String DefaultOptionsText = "设置选项"; - private static final String DefaultEnableText = "开启描述"; - private static final String DefaultDisableText = "关闭描述"; + private static final String DefaultHintText = "设置描述"; private String mOptionsText = ""; - private String mEnableText = ""; - private String mDisableText = ""; + private String mHintText = ""; private int mOptionsTextColor = 0xFF000000; private int mHintTextColor = 0xFF9D9D9D; @@ -41,6 +46,43 @@ public class SettingItem extends ConstraintLayout { private boolean mLinkage = true; private boolean mShowDivider = true; + // ============================================================ + // 联动配置(方案 A:View 内部自管理) + // ============================================================ + /** 本项对外暴露的联动名称(供其他项依赖)。为空时默认使用 optionsText。 */ + private String mLinkageName = ""; + /** 本项依赖的联动名称:仅当名称为该值的项开启时,本项才可用。为空表示不依赖任何项。 */ + private String mEnabledBy = ""; + /** 是否已被外部通过 setEnabled(false) 显式禁用。 */ + private boolean mHardDisabled = false; + + /** 本项实际是否可交互(联动 + 硬禁用综合结果)。 */ + private boolean mInteractive = true; + + /** 联动注册表:以根视图对象为作用域,key 为联动名称,value 为该名称下的 SettingItem 列表。 */ + private static final Map>> sLinkageRegistry = new HashMap<>(); + + /** 本项注册时缓存的作用域 key,用于 detach 时精确移除。 */ + private Object mScope; + + // ============================================================ + // 配置自动读写(方案:XML 配置 configKey,自动同步 MMKV) + // ============================================================ + /** 绑定的 MMKV 配置 key(int 0/1)。为空表示不自动读写配置。 */ + private String mConfigKey = ""; + /** 配置默认值(读不到时使用)。 */ + private boolean mConfigDefault = false; + + /** 复用的 MMKV 实例(与页面读取使用同一进程 ID,保证一致)。 */ + private static MMKV sMMKV; + + private static MMKV getMMKV() { + if (sMMKV == null) { + sMMKV = MMKV.mmkvWithID(CommonConfig.MMKV_ID, MMKV.MULTI_PROCESS_MODE); + } + return sMMKV; + } + private ConstraintLayout cl_root; private TextView tv_options, tv_hint; private SwitchButton switch_button; @@ -53,27 +95,19 @@ public class SettingItem extends ConstraintLayout { public void setOnToggleChanged(SwitchButton.OnCheckedChangeListener onToggleChanged) { mOnToggleChanged = onToggleChanged; - switch_button.setOnCheckedChangeListener(mOnToggleChanged); + // 仅记录外部监听,实际派发统一走 init() 中设置的内部监听器,确保联动逻辑不被覆盖 } public void setToggleStatu(boolean on) { switch_button.setChecked(on); - if (on) { - if (!TextUtils.isEmpty(mEnableText)) { - tv_hint.setText(mEnableText); - } else { - tv_hint.setText(DefaultEnableText); - } - } else { - if (!TextUtils.isEmpty(mDisableText)) { - tv_hint.setText(mDisableText); - } else { - tv_hint.setText(DefaultDisableText); - } - } requestLayout(); } + /** 描述文字与开关状态无关,统一显示固定描述 */ + private String getHintText() { + return TextUtils.isEmpty(mHintText) ? DefaultHintText : mHintText; + } + public boolean isChecked() { return switch_button.isChecked(); } @@ -122,11 +156,10 @@ public class SettingItem extends ConstraintLayout { tv_options.setTextColor(optionsTextColor); - mEnableText = typedArray.getString(R.styleable.SettingItem_enableText); - mDisableText = typedArray.getString(R.styleable.SettingItem_disableText); + mHintText = typedArray.getString(R.styleable.SettingItem_hintText); - int enableTextColor = typedArray.getColor(R.styleable.SettingItem_enableTextColor, mHintTextColor); - tv_hint.setTextColor(enableTextColor); + int hintTextColor = typedArray.getColor(R.styleable.SettingItem_hintTextColor, mHintTextColor); + tv_hint.setTextColor(hintTextColor); // boolean rootClick = typedArray.getBoolean(R.styleable.SettingItem_rootClick, mRootClick); // if (rootClick) { @@ -139,12 +172,16 @@ public class SettingItem extends ConstraintLayout { mShowMore = typedArray.getBoolean(R.styleable.SettingItem_showMore, mShowMore); mLinkage = typedArray.getBoolean(R.styleable.SettingItem_linkage, mLinkage); mShowDivider = typedArray.getBoolean(R.styleable.SettingItem_dividerLine, mShowDivider); + mLinkageName = typedArray.getString(R.styleable.SettingItem_linkageName); + mEnabledBy = typedArray.getString(R.styleable.SettingItem_enabledBy); + mConfigKey = typedArray.getString(R.styleable.SettingItem_configKey); + mConfigDefault = typedArray.getBoolean(R.styleable.SettingItem_configDefault, false); } finally { typedArray.recycle(); } } else { tv_options.setText(mOptionsText); - tv_hint.setText(mEnableText); + tv_hint.setText(getHintText()); tv_options.setTextColor(mOptionsTextColor); tv_hint.setTextColor(mHintTextColor); } @@ -169,36 +206,19 @@ public class SettingItem extends ConstraintLayout { dividerLine.setVisibility(GONE); } - if (switch_button.isChecked()) { - if (!TextUtils.isEmpty(mEnableText)) { - tv_hint.setText(mEnableText); - } else { - tv_hint.setText(DefaultEnableText); - } - } else { - if (!TextUtils.isEmpty(mDisableText)) { - tv_hint.setText(mDisableText); - } else { - tv_hint.setText(DefaultDisableText); - } - } + // 描述文字与开关状态无关,统一显示固定描述 + tv_hint.setText(getHintText()); + + // 监听开关状态:自动写入配置 → 刷新依赖本项的子项联动 → 触发外部监听 switch_button.setOnCheckedChangeListener(new SwitchButton.OnCheckedChangeListener() { @Override public void onCheckedChanged(SwitchButton view, boolean isChecked) { - if (isChecked) { - if (!TextUtils.isEmpty(mEnableText)) { - tv_hint.setText(mEnableText); - } else { - tv_hint.setText(DefaultEnableText); - } - } else { - if (!TextUtils.isEmpty(mDisableText)) { - tv_hint.setText(mDisableText); - } else { - tv_hint.setText(DefaultDisableText); - } - } requestLayout(); + autoWriteConfig(isChecked); + notifyDependents(); + if (mOnToggleChanged != null) { + mOnToggleChanged.onCheckedChanged(view, isChecked); + } } }); if (mRootOnClickListener != null) { @@ -208,6 +228,10 @@ public class SettingItem extends ConstraintLayout { cl_root.setOnClickListener(new OnClickListener() { @Override public void onClick(View v) { + // 被联动禁用时整行不可点击 + if (!mInteractive) { + return; + } if (switch_button.isChecked()) { switch_button.setChecked(false); } else { @@ -219,10 +243,233 @@ public class SettingItem extends ConstraintLayout { } + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + // 1. 自动读取配置,恢复开关状态 + autoLoadConfig(); + // 2. 此时 getRootView() 有效,基于根视图作用域注册,并应用初始联动状态 + registerLinkage(); + } + + // ============================================================ + // 配置自动读写 + // ============================================================ + + /** + * 自动从 MMKV 读取配置(int 0/1)并恢复开关状态。 + * 仅在配置了 configKey 时生效。 + */ + private void autoLoadConfig() { + if (TextUtils.isEmpty(mConfigKey)) { + return; + } + int value = getMMKV().decodeInt(mConfigKey, mConfigDefault ? 1 : 0); + switch_button.setChecked(value == 1); + } + + /** + * 自动将当前开关状态写入 MMKV 配置(int 0/1)。 + * 仅在配置了 configKey 时生效。 + */ + private void autoWriteConfig(boolean isChecked) { + if (TextUtils.isEmpty(mConfigKey)) { + return; + } + getMMKV().encode(mConfigKey, isChecked ? 1 : 0); + } + + /** + * 设置绑定的 MMKV 配置 key(int 0/1),并立即同步一次状态。 + */ + public void setConfigKey(String key) { + mConfigKey = key; + autoLoadConfig(); + } + + /** + * 当前绑定的配置 key,未绑定返回 null。 + */ + public String getConfigKey() { + return mConfigKey; + } + + // ============================================================ + // 联动对外接口 + // ============================================================ + + /** + * 设置本项对外暴露的联动名称(供其他项依赖)。 + */ + public void setLinkageName(String name) { + mLinkageName = name; + } + + /** + * 设置本项依赖的联动名称:仅当名称为该值的项开启时,本项才可用。 + */ + public void setEnabledBy(String name) { + mEnabledBy = name; + refreshLinkage(); + } + + /** + * 联动判断:外部显式禁用时始终禁用。 + */ @Override public void setEnabled(boolean enabled) { - super.setEnabled(enabled); - switch_button.setEnabled(enabled); + mHardDisabled = !enabled; + refreshLinkage(); + } + + /** + * 根据联动依赖与外部禁用状态,综合计算本项是否可交互,并刷新视图外观。 + */ + private void refreshLinkage() { + boolean enabledByParent = true; + if (!TextUtils.isEmpty(mEnabledBy)) { + enabledByParent = isAnyOfLinkageEnabled(mEnabledBy); + } + mInteractive = !mHardDisabled && enabledByParent; + + cl_root.setEnabled(mInteractive); + switch_button.setEnabled(mInteractive); + tv_options.setAlpha(mInteractive ? 1.0f : 0.4f); + tv_hint.setAlpha(mInteractive ? 1.0f : 0.4f); + + // 禁用时保持开关状态原样,仅置灰不可交互,不强制改动开关状态 + invalidate(); + } + + // ============================================================ + // 联动注册表(作用域:父容器对象) + // ============================================================ + + /** + * 获取联动作用域 key:取根视图(同一 Activity 内所有 item 共享同一根视图,可隔离不同页面)。 + * 仅在已 attach(getRootView() 有效)时使用;未 attach 时退回自身(不会参与联动匹配)。 + */ + private Object getScope() { + if (mScope != null) { + return mScope; + } + View root = getRootView(); + return (root != null) ? root : this; + } + + /** + * 将本项按联动名称注册进作用域注册表,并立即刷新一次联动状态。 + */ + private void registerLinkage() { + mScope = getScope(); + String name = resolveLinkageName(); + if (TextUtils.isEmpty(name)) { + refreshLinkage(); + return; + } + Map> scopeMap = sLinkageRegistry.get(mScope); + if (scopeMap == null) { + scopeMap = new HashMap<>(); + sLinkageRegistry.put(mScope, scopeMap); + } + List list = scopeMap.get(name); + if (list == null) { + list = new ArrayList<>(); + scopeMap.put(name, list); + } + if (!list.contains(this)) { + list.add(this); + } + // 注册后立即基于当前开关状态刷新自身 + refreshLinkage(); + } + + /** + * 解析本项的联动名称:优先用显式配置,否则退回 optionsText。 + */ + private String resolveLinkageName() { + if (!TextUtils.isEmpty(mLinkageName)) { + return mLinkageName; + } + if (!TextUtils.isEmpty(mOptionsText)) { + return mOptionsText; + } + return ""; + } + + /** + * 判断作用域内名称为 name 的项中,是否存在任一开启状态。 + * 若无注册项则返回 true(无约束,允许使用)。 + */ + private boolean isAnyOfLinkageEnabled(String name) { + Object scope = getScope(); + Map> scopeMap = sLinkageRegistry.get(scope); + if (scopeMap == null) { + return true; + } + List list = scopeMap.get(name); + if (list == null || list.isEmpty()) { + return true; + } + for (SettingItem item : list) { + if (item.switch_button != null && item.switch_button.isChecked()) { + return true; + } + } + return false; + } + + /** + * 当本项(作为父项)状态变化时,刷新所有依赖它的子项。 + */ + private void notifyDependents() { + Object scope = getScope(); + String name = resolveLinkageName(); + if (TextUtils.isEmpty(name)) { + return; + } + Map> scopeMap = sLinkageRegistry.get(scope); + if (scopeMap == null) { + return; + } + List dependents = new ArrayList<>(); + for (List list : scopeMap.values()) { + for (SettingItem item : list) { + if (item != this && mEnabledByRef(item, name)) { + dependents.add(item); + } + } + } + for (SettingItem item : dependents) { + item.refreshLinkage(); + } + } + + private boolean mEnabledByRef(SettingItem item, String name) { + return !TextUtils.isEmpty(item.mEnabledBy) && item.mEnabledBy.equals(name); + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + unregisterLinkage(); + } + + /** + * 从作用域注册表移除本项,避免内存泄漏。 + */ + private void unregisterLinkage() { + if (mScope == null) { + return; + } + Map> scopeMap = sLinkageRegistry.get(mScope); + if (scopeMap == null) { + return; + } + for (List list : scopeMap.values()) { + list.remove(this); + } + mScope = null; } @Override diff --git a/app/src/main/java/com/ttstd/dialer/webrtc/input/InputCommandHandler.java b/app/src/main/java/com/ttstd/dialer/webrtc/input/InputCommandHandler.java new file mode 100644 index 0000000..a911b1a --- /dev/null +++ b/app/src/main/java/com/ttstd/dialer/webrtc/input/InputCommandHandler.java @@ -0,0 +1,161 @@ +package com.ttstd.dialer.webrtc.input; + +import android.util.Log; + +import com.google.protobuf.InvalidProtocolBufferException; +import com.ttstd.control.Action; +import com.ttstd.control.ControlMessage; + +/** + * 处理控制端发来的输入指令(触摸、按键)。 + * 指令以 protobuf 二进制({@link ControlMessage})通过 DataChannel 传输, + * 解析后委托给 {@link InputExecutor} 接口执行。 + */ +public class InputCommandHandler { + + private static final String TAG = "InputCommandHandler"; + private final int screenWidth; + private final int screenHeight; + private final InputExecutor inputExecutor; + + /** 控制端请求切换屏幕采集分辨率时的回调(由 ScreenCaptureService 实现)。 */ + public interface ResolutionRequestListener { + void onResolutionRequested(int width, int height, int fps); + } + + /** 控制端请求切换屏幕串流模式(WebRTC / 自编码)时的回调。 */ + public interface StreamModeListener { + void onStreamModeRequested(int mode); + } + + private ResolutionRequestListener resolutionListener; + private StreamModeListener streamModeListener; + + public InputCommandHandler(int screenWidth, int screenHeight) { + this(screenWidth, screenHeight, new SystemInputUtils()); + } + + /** + * 允许调用方根据权限/环境选择具体的输入执行器。 + */ + public InputCommandHandler(int screenWidth, int screenHeight, InputExecutor inputExecutor) { + this.screenWidth = screenWidth; + this.screenHeight = screenHeight; + this.inputExecutor = inputExecutor; + } + + public void setResolutionRequestListener(ResolutionRequestListener listener) { + this.resolutionListener = listener; + } + + public void setStreamModeListener(StreamModeListener listener) { + this.streamModeListener = listener; + } + + public void handleCommand(byte[] data) { + try { + ControlMessage command = ControlMessage.parseFrom(data); + Action action = command.getAction(); + + switch (action) { + case TOUCH: + handleTouch(command); + break; + case KEY: + handleKey(command); + break; + case SWIPE: + handleSwipe(command); + break; + case LONG_PRESS: + handleLongPress(command); + break; + case MOTION_EVENT: + handleMotionEvent(command); + break; + case SET_RESOLUTION: + if (resolutionListener != null) { + resolutionListener.onResolutionRequested( + command.getWidth(), command.getHeight(), command.getFps()); + } else { + Log.w(TAG, "SET_RESOLUTION received but no listener registered"); + } + break; + case SET_STREAM_MODE: + if (streamModeListener != null) { + streamModeListener.onStreamModeRequested(command.getStreamMode()); + } else { + Log.w(TAG, "SET_STREAM_MODE received but no listener registered"); + } + break; + default: + Log.w(TAG, "Unknown action: " + action); + } + } catch (InvalidProtocolBufferException e) { + Log.e(TAG, "Error parsing command: " + e.getMessage(), e); + } + } + + public void release() { + if (inputExecutor != null) { + inputExecutor.release(); + } + } + + private void handleMotionEvent(ControlMessage command) { + int action = command.getMotionAction(); + float relX = (float) command.getX(); + float relY = (float) command.getY(); + int x = (int) (relX * screenWidth); + int y = (int) (relY * screenHeight); + Log.d(TAG, "Motion event: " + action + " at: " + x + ", " + y); + inputExecutor.injectMotionEvent(action, x, y); + } + + private void handleTouch(ControlMessage command) { + float relX = (float) command.getX(); + float relY = (float) command.getY(); + int x = (int) (relX * screenWidth); + int y = (int) (relY * screenHeight); + + Log.d(TAG, "Touch at: " + x + ", " + y); + inputExecutor.injectTap(x, y); + } + + private void handleKey(ControlMessage command) { + int keyCode = command.getKeyCode(); + if (keyCode == 0) { + Log.w(TAG, "Ignoring KEY command with keyCode=0"); + return; + } + int keyAction = command.getKeyAction(); + Log.d(TAG, "Key press: keyCode=" + keyCode + ", keyAction=" + keyAction); + inputExecutor.injectKeyEvent(keyCode, keyAction); + } + + private void handleSwipe(ControlMessage command) { + float relX1 = (float) command.getX1(); + float relY1 = (float) command.getY1(); + float relX2 = (float) command.getX2(); + float relY2 = (float) command.getY2(); + long duration = command.getDuration(); + + int x1 = (int) (relX1 * screenWidth); + int y1 = (int) (relY1 * screenHeight); + int x2 = (int) (relX2 * screenWidth); + int y2 = (int) (relY2 * screenHeight); + + Log.d(TAG, "Swipe from (" + x1 + "," + y1 + ") to (" + x2 + "," + y2 + ")"); + inputExecutor.injectSwipe(x1, y1, x2, y2, duration); + } + + private void handleLongPress(ControlMessage command) { + float relX = (float) command.getX(); + float relY = (float) command.getY(); + int x = (int) (relX * screenWidth); + int y = (int) (relY * screenHeight); + + Log.d(TAG, "Long press at: " + x + ", " + y); + inputExecutor.injectLongPress(x, y); + } +} diff --git a/app/src/main/java/com/ttstd/dialer/webrtc/input/InputExecutor.java b/app/src/main/java/com/ttstd/dialer/webrtc/input/InputExecutor.java new file mode 100644 index 0000000..a089635 --- /dev/null +++ b/app/src/main/java/com/ttstd/dialer/webrtc/input/InputExecutor.java @@ -0,0 +1,22 @@ +package com.ttstd.dialer.webrtc.input; + +/** + * 输入执行器接口,定义了注入输入事件的标准操作。 + */ +public interface InputExecutor { + void injectTap(int x, int y); + void injectLongPress(int x, int y); + void injectSwipe(int x1, int y1, int x2, int y2, long duration); + /** + * 注入按键事件。 + * @param keyCode Android KeyEvent 键值(如 KeyEvent.KEYCODE_A)。 + * @param keyAction 动作:0=ACTION_DOWN,1=ACTION_UP;传 -1 表示“按下并立即抬起”的一次性点击(兼容旧行为)。 + */ + void injectKeyEvent(int keyCode, int keyAction); + void injectMotionEvent(int action, int x, int y); + + /** + * 释放执行器持有的资源(如持久的 Shell 进程)。 + */ + default void release() {} +} diff --git a/app/src/main/java/com/ttstd/dialer/webrtc/input/RootShellInputUtils.java b/app/src/main/java/com/ttstd/dialer/webrtc/input/RootShellInputUtils.java new file mode 100644 index 0000000..0c98fcc --- /dev/null +++ b/app/src/main/java/com/ttstd/dialer/webrtc/input/RootShellInputUtils.java @@ -0,0 +1,87 @@ +package com.ttstd.dialer.webrtc.input; + +import android.util.Log; + +import java.io.DataOutputStream; +import java.io.IOException; +import java.util.Locale; + +/** + * 通过 Root Shell (su) 执行注入操作的实现类(移植自 WebRTCControlled)。 + */ +public class RootShellInputUtils implements InputExecutor { + private static final String TAG = "RootShellInputUtils"; + + private Process mSuProcess; + private DataOutputStream mSuStream; + + @Override + public void injectTap(int x, int y) { + executeRootCommand(String.format(Locale.US, "input tap %d %d", x, y)); + } + + @Override + public void injectLongPress(int x, int y) { + injectSwipe(x, y, x, y, 1000); + } + + @Override + public void injectSwipe(int x1, int y1, int x2, int y2, long duration) { + executeRootCommand(String.format(Locale.US, "input swipe %d %d %d %d %d", x1, y1, x2, y2, duration)); + } + + @Override + public void injectKeyEvent(int keyCode, int keyAction) { + executeRootCommand(String.format(Locale.US, "input keyevent %d", keyCode)); + } + + @Override + public void injectMotionEvent(int action, int x, int y) { + if (action == 0) { // ACTION_DOWN + injectTap(x, y); + } + } + + private synchronized void executeRootCommand(String command) { + try { + if (mSuProcess == null || mSuStream == null) { + mSuProcess = Runtime.getRuntime().exec("su"); + mSuStream = new DataOutputStream(mSuProcess.getOutputStream()); + } + Log.d(TAG, "Executing root command: " + command); + mSuStream.writeBytes(command + "\n"); + mSuStream.flush(); + } catch (Exception e) { + Log.e(TAG, "Error executing root command: " + command, e); + closeSuProcess(); + } + } + + @Override + public void release() { + closeSuProcess(); + } + + private synchronized void closeSuProcess() { + try { + if (mSuStream != null) { + mSuStream.writeBytes("exit\n"); + mSuStream.flush(); + mSuStream.close(); + } + if (mSuProcess != null) { + mSuProcess.destroy(); + } + } catch (IOException ignored) { + } finally { + mSuStream = null; + mSuProcess = null; + } + } + + @Override + protected void finalize() throws Throwable { + closeSuProcess(); + super.finalize(); + } +} diff --git a/app/src/main/java/com/ttstd/dialer/webrtc/input/ShellInputUtils.java b/app/src/main/java/com/ttstd/dialer/webrtc/input/ShellInputUtils.java new file mode 100644 index 0000000..1fe2878 --- /dev/null +++ b/app/src/main/java/com/ttstd/dialer/webrtc/input/ShellInputUtils.java @@ -0,0 +1,50 @@ +package com.ttstd.dialer.webrtc.input; + +import android.util.Log; + +import java.util.Locale; + +/** + * 通过 Shell 命令(Runtime.exec)执行注入操作的实现类(移植自 WebRTCControlled)。 + */ +public class ShellInputUtils implements InputExecutor { + private static final String TAG = "ShellInputUtils"; + + @Override + public void injectTap(int x, int y) { + executeShellCommand(String.format(Locale.US, "input tap %d %d", x, y)); + } + + @Override + public void injectLongPress(int x, int y) { + injectSwipe(x, y, x, y, 1000); + } + + @Override + public void injectSwipe(int x1, int y1, int x2, int y2, long duration) { + executeShellCommand(String.format(Locale.US, "input swipe %d %d %d %d %d", x1, y1, x2, y2, duration)); + } + + @Override + public void injectKeyEvent(int keyCode, int keyAction) { + executeShellCommand(String.format(Locale.US, "input keyevent %d", keyCode)); + } + + @Override + public void injectMotionEvent(int action, int x, int y) { + if (action == 0) { // ACTION_DOWN + injectTap(x, y); + } + } + + private void executeShellCommand(String command) { + try { + Log.d(TAG, "Executing shell command: " + command); + Process process = Runtime.getRuntime().exec(command); + int exitCode = process.waitFor(); + Log.i(TAG, "Shell command executed with exit code " + exitCode); + } catch (Exception e) { + Log.e(TAG, "Error executing shell command: " + command, e); + } + } +} diff --git a/app/src/main/java/com/ttstd/dialer/webrtc/input/SystemInputUtils.java b/app/src/main/java/com/ttstd/dialer/webrtc/input/SystemInputUtils.java new file mode 100644 index 0000000..3632245 --- /dev/null +++ b/app/src/main/java/com/ttstd/dialer/webrtc/input/SystemInputUtils.java @@ -0,0 +1,112 @@ +package com.ttstd.dialer.webrtc.input; + +import android.os.SystemClock; +import android.util.Log; +import android.util.SparseLongArray; +import android.view.InputDevice; +import android.view.InputEvent; +import android.view.KeyEvent; +import android.view.MotionEvent; + +import java.lang.reflect.Method; + +/** + * 使用系统隐藏 API android.hardware.input.InputManager 进行事件注入的实现类。 + * ElderlyDialer 为系统签名应用(sharedUserId="android.uid.system"),满足注入前提。 + */ +public class SystemInputUtils implements InputExecutor { + private static final String TAG = "SystemInputUtils"; + + private Object mInputManager; + private Method mInjectInputEventMethod; + private static final int INJECT_INPUT_EVENT_MODE_ASYNC = 0; + private static final int INJECT_INPUT_EVENT_MODE_WAIT_FOR_FINISH = 2; + private long mDownTime; + private final SparseLongArray mKeyDownTimes = new SparseLongArray(); + + public SystemInputUtils() { + try { + Class inputManagerClass = Class.forName("android.hardware.input.InputManager"); + Method getInstanceMethod = inputManagerClass.getDeclaredMethod("getInstance"); + mInputManager = getInstanceMethod.invoke(null); + + mInjectInputEventMethod = inputManagerClass.getMethod("injectInputEvent", InputEvent.class, int.class); + } catch (Exception e) { + Log.e(TAG, "Failed to initialize SystemInputUtils via reflection", e); + } + } + + @Override + public void injectTap(int x, int y) { + long downTime = SystemClock.uptimeMillis(); + injectMotionEvent(downTime, downTime, MotionEvent.ACTION_DOWN, x, y); + injectMotionEvent(downTime, SystemClock.uptimeMillis(), MotionEvent.ACTION_UP, x, y); + } + + @Override + public void injectLongPress(int x, int y) { + long downTime = SystemClock.uptimeMillis(); + injectMotionEvent(downTime, downTime, MotionEvent.ACTION_DOWN, x, y); + SystemClock.sleep(1000); + injectMotionEvent(downTime, SystemClock.uptimeMillis(), MotionEvent.ACTION_UP, x, y); + } + + @Override + public void injectSwipe(int x1, int y1, int x2, int y2, long duration) { + // 滑动在系统注入路径下逐级 MOVE 较慢,源仓库已注释;保留接口占位 + } + + @Override + public void injectKeyEvent(int keyCode, int keyAction) { + if (mInputManager == null || mInjectInputEventMethod == null) { + return; + } + long now = SystemClock.uptimeMillis(); + if (keyAction < 0) { + injectKeyEvent(now, now, KeyEvent.ACTION_DOWN, keyCode); + injectKeyEvent(now, now, KeyEvent.ACTION_UP, keyCode); + return; + } + int action = (keyAction == 0) ? KeyEvent.ACTION_DOWN : KeyEvent.ACTION_UP; + long downTime; + if (action == KeyEvent.ACTION_DOWN) { + downTime = now; + mKeyDownTimes.put(keyCode, downTime); + } else { + downTime = mKeyDownTimes.get(keyCode, now); + mKeyDownTimes.delete(keyCode); + } + injectKeyEvent(downTime, now, action, keyCode); + } + + @Override + public void injectMotionEvent(int action, int x, int y) { + long now = SystemClock.uptimeMillis(); + if (action == MotionEvent.ACTION_DOWN) { + mDownTime = now; + } + injectMotionEvent(mDownTime, now, action, x, y); + } + + private void injectMotionEvent(long downTime, long eventTime, int action, float x, float y) { + MotionEvent event = MotionEvent.obtain(downTime, eventTime, action, x, y, 0); + event.setSource(InputDevice.SOURCE_TOUCHSCREEN); + try { + mInjectInputEventMethod.invoke(mInputManager, event, INJECT_INPUT_EVENT_MODE_ASYNC); + } catch (Exception e) { + Log.e(TAG, "Error injecting motion event", e); + } finally { + event.recycle(); + } + } + + private void injectKeyEvent(long downTime, long eventTime, int action, int keyCode) { + KeyEvent event = new KeyEvent(downTime, eventTime, action, keyCode, 0); + event.setSource(InputDevice.SOURCE_KEYBOARD); + try { + mInjectInputEventMethod.invoke(mInputManager, event, INJECT_INPUT_EVENT_MODE_ASYNC); + } catch (Exception e) { + Log.e(TAG, "Error injecting key event", e); + } + } +} diff --git a/app/src/main/java/com/ttstd/dialer/webrtc/service/ScreenCaptureActivity.java b/app/src/main/java/com/ttstd/dialer/webrtc/service/ScreenCaptureActivity.java new file mode 100644 index 0000000..82b4081 --- /dev/null +++ b/app/src/main/java/com/ttstd/dialer/webrtc/service/ScreenCaptureActivity.java @@ -0,0 +1,83 @@ +package com.ttstd.dialer.webrtc.service; + +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.util.Log; + +import androidx.annotation.Nullable; + +/** + * 屏幕采集授权 Activity(被控端)。 + * + * 触发流程:设备端收到镜像命令(PushExecutor.mirror / 信令 MIRROR_START)后, + * 启动本 Activity 申请 MediaProjection 屏幕录制权限;授权成功后把 + * resultCode + data 传给 {@link ScreenCaptureService} 并关闭自身。 + * + * 老人设备交互最少化:本 Activity 启动即自动发起授权,老人只需在弹出的 + * 系统授权框点击"允许"(或由后台/系统策略自动允许)。 + */ +public class ScreenCaptureActivity extends Activity { + + private static final String TAG = "ScreenCaptureActivity"; + private static final int REQUEST_MEDIA_PROJECTION = 1; + + public static void start(Context context) { + Intent intent = new Intent(context, ScreenCaptureActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + context.startActivity(intent); + } + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + requestMediaProjection(); + } + + private void requestMediaProjection() { + MediaProjectionManager mpManager = + (MediaProjectionManager) getSystemService(Context.MEDIA_PROJECTION_SERVICE); + if (mpManager == null) { + Log.e(TAG, "MediaProjectionManager unavailable"); + finish(); + return; + } + try { + Intent captureIntent = mpManager.createScreenCaptureIntent(); + startActivityForResult(captureIntent, REQUEST_MEDIA_PROJECTION); + } catch (Exception e) { + Log.e(TAG, "createScreenCaptureIntent failed", e); + finish(); + } + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (requestCode != REQUEST_MEDIA_PROJECTION) { + finish(); + return; + } + if (resultCode == RESULT_OK && data != null) { + // 保存静态授权结果,供 Service 在跨进程传递 Token 失效时兜底 + ScreenCaptureService.sResultCode = resultCode; + ScreenCaptureService.sResultData = data; + + Intent serviceIntent = new Intent(this, ScreenCaptureService.class); + serviceIntent.putExtra(ScreenCaptureService.EXTRA_RESULT_CODE, resultCode); + serviceIntent.putExtra(ScreenCaptureService.EXTRA_RESULT_DATA, data); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + startForegroundService(serviceIntent); + } else { + startService(serviceIntent); + } + Log.i(TAG, "MediaProjection granted, starting ScreenCaptureService"); + } else { + Log.w(TAG, "MediaProjection denied"); + } + finish(); + } +} diff --git a/app/src/main/java/com/ttstd/dialer/webrtc/service/ScreenCaptureService.java b/app/src/main/java/com/ttstd/dialer/webrtc/service/ScreenCaptureService.java new file mode 100644 index 0000000..48e2391 --- /dev/null +++ b/app/src/main/java/com/ttstd/dialer/webrtc/service/ScreenCaptureService.java @@ -0,0 +1,793 @@ +package com.ttstd.dialer.webrtc.service; + +import android.app.Activity; +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.content.pm.ServiceInfo; +import android.media.projection.MediaProjection; +import android.os.Binder; +import android.os.Build; +import android.os.Handler; +import android.os.IBinder; +import android.os.Looper; +import android.util.DisplayMetrics; +import android.util.Log; +import android.view.WindowManager; +import android.widget.Toast; + +import androidx.annotation.Nullable; +import androidx.core.app.NotificationCompat; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.ttstd.control.Action; +import com.ttstd.control.ControlMessage; +import com.ttstd.dialer.activity.main.MainActivity; +import com.ttstd.dialer.config.CommonConfig; +import com.ttstd.dialer.mdm.DeviceManagerService; +import com.ttstd.dialer.utils.Logger; +import com.ttstd.dialer.webrtc.input.InputCommandHandler; +import com.ttstd.dialer.webrtc.input.InputExecutor; +import com.ttstd.dialer.webrtc.input.RootShellInputUtils; +import com.ttstd.dialer.webrtc.input.ShellInputUtils; +import com.ttstd.dialer.webrtc.input.SystemInputUtils; +import com.ttstd.dialer.webrtc.signaling.SignalMessage; +import com.ttstd.dialer.webrtc.signaling.WebSocketClient; +import com.ttstd.dialer.webrtc.utils.InputSettings; +import com.ttstd.dialer.webrtc.utils.SignatureUtils; +import com.ttstd.dialer.webrtc.webrtc.SelfCodecEncoder; +import com.ttstd.dialer.webrtc.webrtc.WebRtcClient; + +import org.webrtc.EglBase; +import org.webrtc.IceCandidate; +import org.webrtc.ScreenCapturerAndroid; + +import java.util.ArrayList; +import java.util.List; + +/** + * 屏幕采集与远程控制服务(被控端)。 + * + * 移植自 {@code WebRTCControlled} 的 ScreenCaptureService,按平台原有用户逻辑做了适配: + * 1. 不依赖源项目的 token 激活流程(BaseService/ViewModel/DeviceRepository/AuthSettings), + * 设备端信令走 SN + 签名头鉴权(见 {@link WebSocketClient}),身份由信令服务器识别。 + * 2. 不依赖源项目的 ConnectionRequestActivity / MainActivity 强耦合,改为回调接口 + * ({@link #setStateListener} / {@link #setConnectionListener}),由设备端按需接入确认交互。 + * 3. 保留核心能力:MediaProjection 屏幕采集、WebRTC 编排(Answer 方)、信令收发、 + * 触摸/按键指令注入(系统签名路径)、分辨率/帧率切换、自编码(H264)旁路。 + */ +public class ScreenCaptureService extends Service { + + private static final String TAG = "ScreenCaptureService"; + private static final String CHANNEL_ID = "screen_capture_channel"; + private static final int NOTIFICATION_ID = 1; + + public static final String EXTRA_RESULT_CODE = "result_code"; + public static final String EXTRA_RESULT_DATA = "result_data"; + + /** 静态授权结果,解决部分设备跨进程/Intent 传递 Intent 时 Token 失效的问题 */ + public static int sResultCode = Activity.RESULT_CANCELED; + public static Intent sResultData = null; + + private ScreenCapturerAndroid screenCapturer; + private WebSocketClient wsClient; + private WebRtcClient webRtcClient; + private InputCommandHandler inputHandler; + private EglBase eglBase; + private final Gson gson = new Gson(); + + private boolean isInitialized = false; + + /** 静态实例引用,供外部(确认逻辑/停止逻辑)回调本服务。 */ + private static ScreenCaptureService instance; + + public static ScreenCaptureService getInstance() { + return instance; + } + + /** 设备序列号,作为注册到信令服务器的 deviceId。 */ + private String deviceId; + /** 信令监听器引用,重连时复用。 */ + private WebSocketClient.SignalListener signalListener; + /** 当前待处理的连接请求(OFFER)信息。 */ + private String pendingControllerId; + private String pendingControllerName; + private String pendingOfferSdp; + private String controllingName; + + private int realScreenWidth; + private int realScreenHeight; + private int currentCaptureWidth; + private int currentCaptureHeight; + private int currentCaptureFps; + private int maxCaptureFps = 60; + private List supportedFpsList = new ArrayList<>(); + + private final Handler mainHandler = new Handler(Looper.getMainLooper()); + private boolean isShuttingDown = false; + + private int resultCode; + private Intent resultDataIntent; + private int currentStreamMode = SelfCodecEncoder.STREAM_MODE_WEBRTC; + private SelfCodecEncoder selfEncoder; + + // ---- 回调接口(替代源项目的 Activity 绑定耦合)---- + public interface ServiceStateListener { + void onSignalConnected(); + void onSignalDisconnected(); + void onControllerDisconnected(String name); + void onError(String message); + } + + private ServiceStateListener stateListener; + + public void setStateListener(ServiceStateListener listener) { + this.stateListener = listener; + } + + public interface ResolutionChangeListener { + void onResolutionChanged(int width, int height, int fps, boolean fromRemote); + } + + private ResolutionChangeListener resolutionListener; + + public void setResolutionListener(ResolutionChangeListener listener) { + this.resolutionListener = listener; + } + + /** 收到远程连接请求(OFFER)时的回调,由设备端决定是否接受。 */ + public interface ConnectionListener { + void onConnectionRequested(String controllerId, String controllerName, String offerSdp); + } + + private ConnectionListener connectionListener; + + public void setConnectionListener(ConnectionListener listener) { + this.connectionListener = listener; + } + + // ---- 静态启动/授权辅助 ---- + public static ScreenCaptureService sInstance; + + @Override + public void onCreate() { + super.onCreate(); + instance = this; + sInstance = this; + createNotificationChannel(); + deviceId = resolveDeviceId(); + initScreenMetrics(); + + // 尽早注册信令监听器:wsClient 为全局单例、可能早已连接, + // 若等 startScreenCapture 才注册,OFFER 可能在注册前到达而丢失(导致不显示画面)。 + buildSignalListener(); + WebSocketClient.getInstance().addSignalListener(signalListener); + } + + /** 构建信令监听器(全局单例 wsClient 的消息分发到本服务处理)。 */ + private void buildSignalListener() { + this.signalListener = new WebSocketClient.SignalListener() { + @Override + public void onRegistered(String fromDeviceId) { + if (fromDeviceId != null && !fromDeviceId.isEmpty()) { + deviceId = fromDeviceId; + Log.i(TAG, "REGISTER_SUCCESS deviceId=" + fromDeviceId); + } + } + + @Override + public void onConnected() { + Log.i(TAG, "Connected to signal server"); + isShuttingDown = false; + updateNotification("远程控制服务正在运行"); + if (stateListener != null) stateListener.onSignalConnected(); + } + + @Override + public void onDisconnected() { + Log.i(TAG, "Disconnected from signal server"); + mainHandler.post(() -> onSignalServerDisconnected()); + } + + @Override + public void onError(String error) { + Log.e(TAG, "WebSocket onError: " + error); + mainHandler.post(() -> onSignalServerDisconnected()); + } + + @Override + public void onMessage(SignalMessage message) { + handleSignalMessage(message); + } + + @Override + public void onTokenExpired() { + Log.w(TAG, "令牌失效(4001),停止服务"); + mainHandler.post(() -> { + if (stateListener != null) stateListener.onError("连接已失效,请重新启动远程控制"); + stopSelf(); + }); + } + + @Override + public void onForceLogout() { + Log.w(TAG, "强制下线(4003),停止服务"); + mainHandler.post(() -> { + if (stateListener != null) stateListener.onError("已被强制下线"); + stopSelf(); + }); + } + }; + } + + private String resolveDeviceId() { + try { + String sn = DeviceManagerService.getInstance().getSerial(); + return sn != null ? sn : ""; + } catch (Exception e) { + Log.e(TAG, "resolveDeviceId failed", e); + return ""; + } + } + + private void initScreenMetrics() { + WindowManager wm = (WindowManager) getSystemService(Context.WINDOW_SERVICE); + DisplayMetrics metrics = new DisplayMetrics(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + wm.getDefaultDisplay().getRealMetrics(metrics); + } else { + wm.getDefaultDisplay().getRealMetrics(metrics); + } + realScreenWidth = metrics.widthPixels; + realScreenHeight = metrics.heightPixels; + try { + maxCaptureFps = (int) wm.getDefaultDisplay().getRefreshRate(); + } catch (Exception ignored) { + } + if (maxCaptureFps <= 0) maxCaptureFps = 60; + supportedFpsList = buildSupportedFpsList(maxCaptureFps); + Log.i(TAG, "Screen native: " + realScreenWidth + "x" + realScreenHeight + " @ max " + maxCaptureFps + "Hz"); + } + + private static List buildSupportedFpsList(int maxFps) { + List list = new ArrayList<>(); + int[] candidates = {15, 24, 30, 60, 90, 120}; + for (int c : candidates) { + if (c <= maxFps) list.add(c); + } + if (list.isEmpty() || list.get(list.size() - 1) < maxFps) { + list.add(maxFps); + } + return list; + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + if (intent == null) { + stopSelf(); + return START_NOT_STICKY; + } + + Notification notification = createNotification(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION); + } else { + startForeground(NOTIFICATION_ID, notification); + } + + if (isInitialized) { + return START_STICKY; + } + + int resultCode = (sResultCode != Activity.RESULT_CANCELED) + ? sResultCode : intent.getIntExtra(EXTRA_RESULT_CODE, Activity.RESULT_CANCELED); + Intent resultData = (sResultData != null) + ? sResultData : intent.getParcelableExtra(EXTRA_RESULT_DATA); + + this.resultCode = resultCode; + this.resultDataIntent = resultData; + + if (resultCode != Activity.RESULT_OK || resultData == null) { + Log.e(TAG, "Invalid MediaProjection result, stop service"); + showToast("屏幕采集授权失败"); + stopSelf(); + return START_NOT_STICKY; + } + + isInitialized = true; + startScreenCapture(resultCode, resultData); + return START_STICKY; + } + + private void startScreenCapture(int resultCode, Intent resultData) { + eglBase = EglBase.create(); + + String serverUrl = resolveSignalServerUrl(); + if (serverUrl == null || serverUrl.isEmpty()) { + Log.e(TAG, "signal server url empty, stop"); + showToast("信令服务器未配置"); + stopSelf(); + return; + } + + // 默认采集分辨率:用真实分辨率,仅长边 1920 封顶(与源 WebRTCControlled 一致),帧率 30 + int longEdge = getNativeLongEdge(); + int[] size = resolveCaptureSize(longEdge > 1920 ? 1920 : longEdge, 0); + int width = size[0]; + int height = size[1]; + int fps = Math.min(30, maxCaptureFps > 0 ? maxCaptureFps : 30); + currentCaptureWidth = width; + currentCaptureHeight = height; + currentCaptureFps = fps; + + // 信令监听器已在 onCreate 注册到全局单例 wsClient;此处仅获取单例并确保连接。 + wsClient = WebSocketClient.getInstance(); + wsClient.setServerUrl(serverUrl); + if (!wsClient.isConnected()) { + wsClient.connect(); + } + + webRtcClient = new WebRtcClient(this, wsClient, deviceId); + webRtcClient.initialize(eglBase); + // WebRTC 就绪后,若 OFFER 已先到(竞态缓存),立即补应答,保证连接不因时序而中断 + if (pendingOfferSdp != null && pendingControllerId != null) { + Log.i(TAG, "WebRTC 就绪,补应答先到的 OFFER"); + mainHandler.post(this::acceptConnection); + } + webRtcClient.setSupportedFpsList(supportedFpsList); + webRtcClient.setInputCallback(bytes -> inputHandler.handleCommand(bytes)); + webRtcClient.setIceEventListener(() -> mainHandler.post(() -> { + if (currentStreamMode == SelfCodecEncoder.STREAM_MODE_SELF_CODEC && selfEncoder != null) { + selfEncoder.requestKeyFrame(); + updateNotification("远程控制已连接 (自编码)"); + } else { + updateNotification("远程控制已连接"); + } + })); + webRtcClient.setRemoteDisconnectCallback(controllerId -> + mainHandler.post(() -> onControllerDisconnected(controllerId))); + + inputHandler = createInputCommandHandler(); + inputHandler.setResolutionRequestListener(this::onRemoteResolutionRequested); + inputHandler.setStreamModeListener(this::onStreamModeRequested); + + screenCapturer = new ScreenCapturerAndroid(resultData, new MediaProjection.Callback() { + @Override + public void onStop() { + super.onStop(); + Log.e(TAG, "MediaProjection stopped (ScreenCapturerAndroid)"); + } + }); + + webRtcClient.setVideoCapturer(screenCapturer, width, height, fps); + } + + /** 解析信令服务器地址(ws:// 或 wss://)。 */ + private String resolveSignalServerUrl() { + return CommonConfig.SIGNAL_SERVER_URL; + } + + /** 创建输入指令处理器(系统签名 → SystemInputUtils;否则 Root/Shell 兜底)。 */ + private InputCommandHandler createInputCommandHandler() { + InputExecutor executor; + String method = InputSettings.getInputMethod(this); + if (InputSettings.METHOD_AUTO.equals(method) || InputSettings.METHOD_SYSTEM.equals(method)) { + if (SignatureUtils.isSystemSignature(this) || SignatureUtils.isSharedSystemUid(this)) { + Log.i(TAG, "Input executor: SystemInputUtils (system)"); + executor = new SystemInputUtils(); + } else if (SignatureUtils.isDeviceRooted() || SignatureUtils.isAppHasRootPermission()) { + Log.i(TAG, "Input executor: RootShellInputUtils (rooted)"); + executor = new RootShellInputUtils(); + } else { + Log.i(TAG, "Input executor: ShellInputUtils (fallback)"); + executor = new ShellInputUtils(); + } + } else if (InputSettings.METHOD_ROOT.equals(method)) { + executor = new RootShellInputUtils(); + } else if (InputSettings.METHOD_SHELL.equals(method)) { + executor = new ShellInputUtils(); + } else { + // 默认系统签名注入 + executor = new SystemInputUtils(); + } + return new InputCommandHandler(realScreenWidth, realScreenHeight, executor); + } + + // ---- 信令消息处理 ---- + + private void handleSignalMessage(SignalMessage message) { + String type = message.getType(); + if (type == null) return; + switch (type.toUpperCase()) { + case "OFFER": + handleOffer(message); + break; + case "ICE_CANDIDATE": + handleIceCandidate(message); + break; + } + } + + private void handleOffer(SignalMessage message) { + if (pendingControllerId != null) { + Log.i(TAG, "Ignore OFFER from " + message.getFromDeviceId() + ": a request is already pending"); + return; + } + + String offerSdp = null; + String controllerName = null; + try { + JsonObject payload = gson.fromJson(message.getPayload(), JsonObject.class); + offerSdp = payload.get("sdp").getAsString(); + if (payload.has("username") && !payload.get("username").isJsonNull()) { + controllerName = payload.get("username").getAsString(); + } + } catch (Exception e) { + Log.e(TAG, "Error parsing offer SDP", e); + return; + } + + String controllerId = message.getFromDeviceId(); + pendingControllerId = controllerId; + pendingControllerName = (controllerName != null && !controllerName.isEmpty()) + ? controllerName : controllerId; + pendingOfferSdp = offerSdp; + + // 鉴权模式:目标平台由信令服务统一鉴权(client JWT + 绑定校验),设备端不重复校验, + // 仅保留"是否允许远程控制"的总开关(由设备端设置,默认允许)。 + if (connectionListener != null) { + connectionListener.onConnectionRequested(controllerId, pendingControllerName, offerSdp); + } else { + // 未接入确认回调:直接接受(跟随平台"允许远程控制"总开关)。 + mainHandler.post(this::acceptConnection); + } + } + + /** 接受连接:创建 Answer 完成连接。 */ + public void acceptConnection() { + // 竞态保护:WebRTC 尚未初始化完成时(OFFER 先于 webRtcClient 到达),延迟重试。 + if (pendingOfferSdp != null && pendingControllerId != null && webRtcClient == null) { + Log.w(TAG, "acceptConnection: webRtcClient 未就绪,延迟重试"); + mainHandler.postDelayed(this::acceptConnection, 200); + return; + } + if (pendingOfferSdp != null && pendingControllerId != null && webRtcClient != null) { + String offerSdp = pendingOfferSdp; + String controllerId = pendingControllerId; + controllingName = pendingControllerName; + clearPendingRequest(); + updateNotification("正在接受 " + controllingName + " 的控制..."); + webRtcClient.createPeerConnectionAndAnswer(offerSdp, controllerId); + } + } + + /** 拒绝连接:向控制端回送拒绝消息。 */ + public void rejectConnection() { + if (pendingControllerId != null && wsClient != null) { + String controllerId = pendingControllerId; + clearPendingRequest(); + sendConnectionRejected(controllerId); + } + } + + private void clearPendingRequest() { + pendingControllerId = null; + pendingControllerName = null; + pendingOfferSdp = null; + } + + private void handleIceCandidate(SignalMessage message) { + try { + JsonObject payload = gson.fromJson(message.getPayload(), JsonObject.class); + String sdpMid = payload.get("sdpMid").getAsString(); + int sdpMLineIndex = payload.get("sdpMLineIndex").getAsInt(); + String candidate = payload.get("candidate").getAsString(); + IceCandidate iceCandidate = new IceCandidate(sdpMid, sdpMLineIndex, candidate); + webRtcClient.addIceCandidate(iceCandidate); + } catch (Exception e) { + Log.e(TAG, "Error parsing ICE candidate", e); + } + } + + private void onSignalServerDisconnected() { + if (isShuttingDown) return; + Log.i(TAG, "Signal server disconnected, will auto-reconnect"); + updateNotification("信令断开,正在重连..."); + if (stateListener != null) stateListener.onSignalDisconnected(); + } + + private void onControllerDisconnected(String controllerId) { + if (isShuttingDown) return; + String name = (controllingName != null && !controllingName.isEmpty()) + ? controllingName : controllerId; + Log.i(TAG, "Remote controller disconnected: " + name); + updateNotification("远程控制服务正在运行"); + if (stateListener != null) stateListener.onControllerDisconnected(name); + controllingName = null; + } + + private void sendConnectionRejected(String controllerId) { + SignalMessage msg = new SignalMessage(); + msg.setType("CONNECTION_REJECTED"); + msg.setFromDeviceId(deviceId); + msg.setToDeviceId(controllerId); + msg.setDeviceType("CONTROLLED"); + JsonObject payload = new JsonObject(); + payload.addProperty("reason", "被控端拒绝了远程连接请求"); + msg.setPayload(payload.toString()); + wsClient.sendMessage(msg); + } + + // ---- 分辨率 / 帧率 / 串流模式 ---- + + private void onRemoteResolutionRequested(int width, int height, int fps) { + requestResolutionChange(width, height, fps, true); + } + + public void changeResolutionByLongEdge(int longEdge) { + requestResolutionChange(longEdge, 0, 0, false); + } + + public void changeFps(int fps) { + requestResolutionChange(currentCaptureWidth, currentCaptureHeight, fps, false); + } + + public List getSupportedFpsList() { + return supportedFpsList; + } + + public int[] getCurrentCaptureResolution() { + return new int[]{currentCaptureWidth, currentCaptureHeight, currentCaptureFps}; + } + + public int getNativeLongEdge() { + return Math.max(realScreenWidth, realScreenHeight); + } + + public void requestResolutionChange(int width, int height, int fps, boolean fromRemote) { + int[] size = resolveCaptureSize(width, height); + int targetW = size[0]; + int targetH = size[1]; + int targetFps = fps > 0 ? fps : currentCaptureFps; + if (targetFps <= 0) targetFps = 30; + if (maxCaptureFps > 0 && targetFps > maxCaptureFps) targetFps = maxCaptureFps; + + if (webRtcClient == null) { + Log.w(TAG, "requestResolutionChange ignored: WebRTC not ready"); + return; + } + + webRtcClient.changeCaptureFormat(targetW, targetH, targetFps); + currentCaptureWidth = targetW; + currentCaptureHeight = targetH; + currentCaptureFps = targetFps; + + if (currentStreamMode == SelfCodecEncoder.STREAM_MODE_SELF_CODEC) { + updateNotification("正在切换分辨率..."); + stopSelfCodecEncoder(); + startSelfCodecEncoder(); + } + + Log.i(TAG, "Capture resolution changed -> " + targetW + "x" + targetH + " @ " + targetFps + "fps (remote=" + fromRemote + ")"); + if (resolutionListener != null) { + resolutionListener.onResolutionChanged(targetW, targetH, targetFps, fromRemote); + } + } + + private int[] resolveCaptureSize(int width, int height) { + int w, h; + if (width <= 0) { + w = realScreenWidth; + h = realScreenHeight; + } else if (height <= 0) { + int longEdge = Math.max(realScreenWidth, realScreenHeight); + float scale = (float) width / longEdge; + w = Math.round(realScreenWidth * scale); + h = Math.round(realScreenHeight * scale); + } else { + w = width; + h = height; + } + if (w % 2 != 0) w--; + if (h % 2 != 0) h--; + if (w <= 0) w = 2; + if (h <= 0) h = 2; + return new int[]{w, h}; + } + + // ---- 自编码模式 ---- + + private void onStreamModeRequested(int mode) { + setStreamMode(mode); + } + + public void setStreamMode(int mode) { + if (mode == currentStreamMode) return; + if (mode == SelfCodecEncoder.STREAM_MODE_SELF_CODEC) { + if (!startSelfCodecEncoder()) { + Log.e(TAG, "switch to self codec failed, stay webrtc mode"); + currentStreamMode = SelfCodecEncoder.STREAM_MODE_WEBRTC; + reportStreamMode(SelfCodecEncoder.STREAM_MODE_WEBRTC); + return; + } + updateNotification("正在使用自编码模式串流..."); + } else { + stopSelfCodecEncoder(); + updateNotification("正在使用 WebRTC 模式串流..."); + } + currentStreamMode = mode; + reportStreamMode(mode); + } + + public int getCurrentStreamMode() { + return currentStreamMode; + } + + private boolean startSelfCodecEncoder() { + if (selfEncoder != null) return true; + if (webRtcClient == null || !webRtcClient.isVideoDataChannelOpen()) { + Log.w(TAG, "startSelfCodecEncoder: video channel not ready yet"); + } + int[] res = getCurrentCaptureResolution(); + selfEncoder = new SelfCodecEncoder( + res[0], res[1], res[2], + webRtcClient != null ? webRtcClient.getVideoDataChannel() : null, + new SelfCodecEncoder.Callback() { + @Override + public void onEncoderStarted(int w, int h, int f) { + Log.i(TAG, "self codec encoder started: " + w + "x" + h); + } + + @Override + public void onEncoderError(String error) { + Log.e(TAG, "self codec encoder error: " + error); + mainHandler.post(() -> setStreamMode(SelfCodecEncoder.STREAM_MODE_WEBRTC)); + } + }); + selfEncoder.start(); + if (!selfEncoder.isRunning()) { + Log.e(TAG, "self codec encoder failed to start, cleaning up"); + stopSelfCodecEncoder(); + return false; + } + selfEncoder.requestKeyFrame(); + if (webRtcClient != null) { + webRtcClient.setSelfCodecFrameListener(frame -> selfEncoder.feedFrame(frame)); + } + return true; + } + + private void stopSelfCodecEncoder() { + if (webRtcClient != null) { + webRtcClient.setSelfCodecFrameListener(null); + } + if (selfEncoder != null) { + selfEncoder.release(); + selfEncoder = null; + } + } + + private void reportStreamMode(int mode) { + if (webRtcClient == null) return; + try { + ControlMessage msg = ControlMessage.newBuilder() + .setAction(Action.REPORT_STREAM_MODE) + .setStreamMode(mode) + .build(); + webRtcClient.sendControlCommand(msg); + } catch (Exception e) { + Log.e(TAG, "reportStreamMode failed", e); + } + } + + // ---- 生命周期 ---- + + public boolean isStreaming() { + return screenCapturer != null && !isShuttingDown; + } + + public String getDeviceUid() { + return deviceId; + } + + public WebRtcClient getWebRtcClient() { + return webRtcClient; + } + + private final IBinder binder = new LocalBinder(); + + public class LocalBinder extends Binder { + public ScreenCaptureService getService() { + return ScreenCaptureService.this; + } + } + + @Nullable + @Override + public IBinder onBind(Intent intent) { + return binder; + } + + @Override + public void onDestroy() { + super.onDestroy(); + isShuttingDown = true; + sResultCode = Activity.RESULT_CANCELED; + sResultData = null; + if (instance == this) instance = null; + if (sInstance == this) sInstance = null; + if (screenCapturer != null) { + screenCapturer.stopCapture(); + screenCapturer.dispose(); + } + if (selfEncoder != null) { + selfEncoder.release(); + selfEncoder = null; + } + if (webRtcClient != null) { + webRtcClient.close(); + } + // 仅移除本会话的监听器,不关闭全局单例连接 + // (全局连接由 siRemoteAssistance 开关控制,见 SettingsAssistActivity)。 + if (wsClient != null && signalListener != null) { + wsClient.removeSignalListener(signalListener); + } + if (eglBase != null) { + eglBase.release(); + eglBase = null; + } + if (inputHandler != null) { + inputHandler.release(); + inputHandler = null; + } + } + + // ---- 通知 ---- + + private void updateNotification(String contentText) { + NotificationManager manager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); + manager.notify(NOTIFICATION_ID, createNotification(contentText)); + } + + private Notification createNotification() { + return createNotification("远程控制服务正在运行"); + } + + private Notification createNotification(String contentText) { + Intent notificationIntent = new Intent(this, MainActivity.class); + PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, notificationIntent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); + + return new NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle("屏幕共享中") + .setContentText(contentText) + .setSmallIcon(android.R.drawable.ic_menu_camera) + .setContentIntent(pendingIntent) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setOngoing(true) + .build(); + } + + private void createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + NotificationChannel channel = new NotificationChannel( + CHANNEL_ID, "屏幕共享服务", NotificationManager.IMPORTANCE_DEFAULT); + channel.setDescription("远程控制屏幕采集前台服务"); + NotificationManager manager = getSystemService(NotificationManager.class); + manager.createNotificationChannel(channel); + } + } + + private void showToast(String text) { + try { + Toast.makeText(this, text, Toast.LENGTH_LONG).show(); + } catch (Exception e) { + Log.w(TAG, "Failed to show toast: " + e.getMessage()); + } + } +} diff --git a/app/src/main/java/com/ttstd/dialer/webrtc/signaling/SignalMessage.java b/app/src/main/java/com/ttstd/dialer/webrtc/signaling/SignalMessage.java new file mode 100644 index 0000000..13688f2 --- /dev/null +++ b/app/src/main/java/com/ttstd/dialer/webrtc/signaling/SignalMessage.java @@ -0,0 +1,42 @@ +package com.ttstd.dialer.webrtc.signaling; + +public class SignalMessage { + + private String type; + private String fromDeviceId; + private String toDeviceId; + private String deviceType; + private String payload; + private String authType; + private String authValue; + + public SignalMessage() {} + + public SignalMessage(String type, String fromDeviceId, String toDeviceId, String payload) { + this.type = type; + this.fromDeviceId = fromDeviceId; + this.toDeviceId = toDeviceId; + this.payload = payload; + } + + public String getType() { return type; } + public void setType(String type) { this.type = type; } + + public String getFromDeviceId() { return fromDeviceId; } + public void setFromDeviceId(String fromDeviceId) { this.fromDeviceId = fromDeviceId; } + + public String getToDeviceId() { return toDeviceId; } + public void setToDeviceId(String toDeviceId) { this.toDeviceId = toDeviceId; } + + public String getDeviceType() { return deviceType; } + public void setDeviceType(String deviceType) { this.deviceType = deviceType; } + + public String getPayload() { return payload; } + public void setPayload(String payload) { this.payload = payload; } + + public String getAuthType() { return authType; } + public void setAuthType(String authType) { this.authType = authType; } + + public String getAuthValue() { return authValue; } + public void setAuthValue(String authValue) { this.authValue = authValue; } +} diff --git a/app/src/main/java/com/ttstd/dialer/webrtc/signaling/WebSocketClient.java b/app/src/main/java/com/ttstd/dialer/webrtc/signaling/WebSocketClient.java new file mode 100644 index 0000000..3db67cd --- /dev/null +++ b/app/src/main/java/com/ttstd/dialer/webrtc/signaling/WebSocketClient.java @@ -0,0 +1,331 @@ +package com.ttstd.dialer.webrtc.signaling; + +import android.content.Context; +import android.util.Log; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.ttstd.dialer.mdm.DeviceManagerService; +import com.ttstd.dialer.network.SecurityUtils; +import com.ttstd.dialer.utils.NativeUtils; + +import java.util.List; +import java.util.SortedMap; +import java.util.TreeMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.WebSocket; +import okhttp3.WebSocketListener; + +/** + * 被控端信令 WebSocket 客户端(移植自 WebRTCControlled,适配 ElderlyDialer 鉴权)。 + * + * 单例说明: + * 信令 WebSocket 为进程内全局唯一的长连接,使用单例 {@link #getInstance()} 统一管理, + * 任意组件(UI、Service、推送)均可通过 {@link #addSignalListener} / {@link #removeSignalListener} + * 订阅连接与消息事件,实现"全局监听"需求。 + * + * 鉴权方式(遵循平台原有用户逻辑,区分设备端): + * 设备端不依赖 Bearer token,而是复用现有 SN + 签名四件套(X-Device-SN/X-Nonce/X-Timestamp/X-Sign), + * 与 HTTP 层 {@code AuthInterceptor} 的签名方式保持一致,由信令服务器识别设备身份。 + * + * 关闭码语义: + * - 4001 令牌失效:清空令牌,调用方应重新建立连接; + * - 4003 强制下线:停止重连,调用方应回到未连接状态。 + */ +public class WebSocketClient { + + private static final String TAG = "WebSocketClient"; + private static volatile WebSocketClient instance; + + private final Gson gson = new Gson(); + /** 全局信令监听器列表(一对多),任意组件均可订阅。 */ + private final List listeners = new CopyOnWriteArrayList<>(); + /** + * 缓存最近一次 OFFER:ScreenCaptureService 可能在 OFFER 到达后才注册监听器, + * 缓存并在注册时重放,避免因时序导致远程连接(OFFER)丢失、不显示画面。 + */ + private volatile SignalMessage pendingOffer; + private String serverUrl; + private String token; // 可选:若信令服务器仍走 Bearer 鉴权时使用,否则可传 null + private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); + + private OkHttpClient client; + private WebSocket ws; + private boolean manualClose = false; + private int reconnectAttempts = 0; + private static final int MAX_RECONNECT_DELAY = 30_000; + + public interface SignalListener { + void onRegistered(String fromDeviceId); + void onMessage(SignalMessage message); + void onConnected(); + void onDisconnected(); + void onError(String message); + /** 令牌失效(关闭码 4001),需重新建立连接。 */ + void onTokenExpired(); + /** 强制下线(关闭码 4003),需停止重连。 */ + void onForceLogout(); + } + + /** + * 获取全局单例(进程内唯一信令连接)。 + */ + public static WebSocketClient getInstance() { + if (instance == null) { + synchronized (WebSocketClient.class) { + if (instance == null) { + instance = new WebSocketClient(); + } + } + } + return instance; + } + + private WebSocketClient() { + // 单例:参数在 connect() 时由调用方传入 + } + + /** + * 注册全局信令监听器(一对多,可多次调用注册不同组件)。 + * 若在注册前已有缓存的 OFFER(监听器晚于 OFFER 到达时注册),立即重放, + * 确保 ScreenCaptureService 能处理因时序早到的远程连接请求。 + */ + public void addSignalListener(SignalListener listener) { + if (listener == null) return; + if (!listeners.contains(listener)) { + listeners.add(listener); + } + // 重放缓存 OFFER,避免时序竞态导致连接丢失 + SignalMessage offer = pendingOffer; + if (offer != null) { + Log.d(TAG, "重放缓存 OFFER 给新注册监听器"); + listener.onMessage(offer); + } + } + + /** + * 移除全局信令监听器。 + */ + public void removeSignalListener(SignalListener listener) { + if (listener != null) { + listeners.remove(listener); + } + } + + /** + * 配置信令服务器地址(需在 {@link #connect()} 之前调用)。 + * 设备端使用 SN + 签名头鉴权,token 可传 null。 + */ + public void setServerUrl(String serverUrl) { + this.serverUrl = serverUrl; + } + + /** + * 配置可选 Bearer token(家属端/管理端控制时),设备端一般为 null。 + */ + public void setToken(String token) { + this.token = token; + } + + public void connect() { + if (serverUrl == null || serverUrl.isEmpty()) { + notifyError("服务器地址为空"); + return; + } + manualClose = false; + + client = new OkHttpClient.Builder() + .pingInterval(20, TimeUnit.SECONDS) + .build(); + + Request.Builder reqBuilder = new Request.Builder().url(serverUrl); + + // 设备端:优先带 SN 签名四件套(与 HTTP 层 AuthInterceptor 完全一致) + try { + String sn = DeviceManagerService.getInstance().getSerial(); + if (sn != null && !sn.isEmpty()) { + String nonce = NativeUtils.getNonce(); + long timestamp = System.currentTimeMillis(); + SortedMap headersMap = new TreeMap<>(); + headersMap.put("X-Device-SN", sn); + headersMap.put("X-Nonce", nonce); + headersMap.put("X-Timestamp", String.valueOf(timestamp)); + String sign = SecurityUtils.generateSign(headersMap); + reqBuilder.addHeader("X-Device-SN", sn); + reqBuilder.addHeader("X-Nonce", nonce); + reqBuilder.addHeader("X-Timestamp", String.valueOf(timestamp)); + reqBuilder.addHeader("X-Sign", sign); + } + } catch (Exception e) { + Log.w(TAG, "附加 SN 签名头失败,继续尝试连接: " + e.getMessage()); + } + + // 可选:Bearer token(家属端/管理端控制时) + if (token != null && !token.isEmpty()) { + reqBuilder.addHeader("Authorization", "Bearer " + token); + } + Request request = reqBuilder.build(); + + ws = client.newWebSocket(request, new WebSocketListener() { + @Override + public void onOpen(WebSocket webSocket, Response response) { + Log.d(TAG, "WebSocket 已连接"); + reconnectAttempts = 0; + for (SignalListener l : listeners) { + l.onConnected(); + } + } + + @Override + public void onMessage(WebSocket webSocket, String text) { + Log.d(TAG, "WebSocket 收到消息: " + text); + SignalMessage msg = parse(text); + if (msg == null) return; + if ("REGISTER_SUCCESS".equals(msg.getType()) && msg.getFromDeviceId() != null) { + for (SignalListener l : listeners) { + l.onRegistered(msg.getFromDeviceId()); + } + } + // 缓存最近一次 OFFER,供后注册的监听器(如 ScreenCaptureService)重放 + if ("OFFER".equalsIgnoreCase(msg.getType())) { + pendingOffer = msg; + Log.d(TAG, "缓存 OFFER: from=" + msg.getFromDeviceId()); + } else if ("CONNECTION_REJECTED".equalsIgnoreCase(msg.getType()) + || "TARGET_OFFLINE".equalsIgnoreCase(msg.getType())) { + // 连接已终结/被拒,清空缓存,避免旧 OFFER 重放导致错连 + pendingOffer = null; + } + for (SignalListener l : listeners) { + l.onMessage(msg); + } + } + + @Override + public void onClosing(WebSocket webSocket, int code, String reason) { + Log.w(TAG, "WebSocket onClosing code=" + code + " reason=" + reason); + if (code == 4001) { + for (SignalListener l : listeners) { + l.onTokenExpired(); + } + webSocket.close(4001, reason); + return; + } + if (code == 4003) { + manualClose = true; + for (SignalListener l : listeners) { + l.onForceLogout(); + } + webSocket.close(4003, reason); + return; + } + webSocket.close(code, reason); + } + + @Override + public void onClosed(WebSocket webSocket, int code, String reason) { + Log.d(TAG, "WebSocket 已关闭 code=" + code); + pendingOffer = null; + if (manualClose) { + notifyDisconnected(); + return; + } + handleReconnect(); + } + + @Override + public void onFailure(WebSocket webSocket, Throwable t, Response response) { + Log.e(TAG, "WebSocket 连接失败: " + t.getMessage(), t); + if (manualClose) { + notifyDisconnected(); + return; + } + notifyError("连接失败: " + t.getMessage()); + handleReconnect(); + } + }); + } + + private void notifyDisconnected() { + for (SignalListener l : listeners) { + l.onDisconnected(); + } + } + + private void notifyError(String message) { + for (SignalListener l : listeners) { + l.onError(message); + } + } + + private void handleReconnect() { + reconnectAttempts++; + long delay = Math.min((long) Math.pow(2, Math.min(reconnectAttempts, 5)) * 1000, MAX_RECONNECT_DELAY); + Log.d(TAG, "第 " + reconnectAttempts + " 次重连,延迟 " + delay + "ms"); + scheduler.schedule(() -> { + if (!manualClose) connect(); + }, delay, TimeUnit.MILLISECONDS); + } + + public void disconnect() { + manualClose = true; + pendingOffer = null; + if (ws != null) { + ws.close(1000, "用户断开"); + ws = null; + } + if (client != null) { + client.dispatcher().executorService().shutdown(); + } + scheduler.shutdownNow(); + } + + private SignalMessage parse(String text) { + try { + JsonObject json = gson.fromJson(text, JsonObject.class); + SignalMessage msg = new SignalMessage(); + if (json.has("type")) msg.setType(getString(json, "type")); + if (json.has("fromDeviceId")) msg.setFromDeviceId(getString(json, "fromDeviceId")); + if (json.has("toDeviceId")) msg.setToDeviceId(getString(json, "toDeviceId")); + if (json.has("deviceType")) msg.setDeviceType(getString(json, "deviceType")); + if (json.has("payload")) msg.setPayload(getString(json, "payload")); + if (json.has("authType")) msg.setAuthType(getString(json, "authType")); + if (json.has("authValue")) msg.setAuthValue(getString(json, "authValue")); + return msg; + } catch (Exception e) { + Log.e(TAG, "消息解析失败: " + e.getMessage(), e); + return null; + } + } + + /** + * 安全读取字符串字段:当字段不存在或为 JSON null 时返回 null, + * 避免对 JsonNull 调用 getAsString() 抛出 UnsupportedOperationException。 + */ + private String getString(JsonObject json, String key) { + if (!json.has(key) || json.get(key).isJsonNull()) { + return null; + } + return json.get(key).getAsString(); + } + + public void sendMessage(SignalMessage message) { + send(gson.toJson(message)); + } + + public void send(String message) { + if (ws != null) { + ws.send(message); + } + } + + public boolean isConnected() { + return ws != null && client != null && !client.dispatcher().executorService().isShutdown(); + } +} diff --git a/app/src/main/java/com/ttstd/dialer/webrtc/utils/InputSettings.java b/app/src/main/java/com/ttstd/dialer/webrtc/utils/InputSettings.java new file mode 100644 index 0000000..091597e --- /dev/null +++ b/app/src/main/java/com/ttstd/dialer/webrtc/utils/InputSettings.java @@ -0,0 +1,34 @@ +package com.ttstd.dialer.webrtc.utils; + +import android.content.Context; + +import com.tencent.mmkv.MMKV; + +/** + * 模拟点击方式(输入执行器)的可选配置。 + * 默认「自动选择」,由 {@code ScreenCaptureService} 根据当前权限/环境挑选最合适的执行器。 + * ElderlyDialer 为系统签名应用,通常自动选择 SystemInputUtils。 + */ +public class InputSettings { + + private static final String STORE_ID = "dialer_webrtc_input_prefs"; + private static final String KEY_INPUT_METHOD = "input_method"; + + private static MMKV kv() { + return MMKV.mmkvWithID(STORE_ID, MMKV.MULTI_PROCESS_MODE); + } + + public static final String METHOD_AUTO = "auto"; + public static final String METHOD_SYSTEM = "system"; + public static final String METHOD_ROOT = "root"; + public static final String METHOD_ACCESSIBILITY = "accessibility"; + public static final String METHOD_SHELL = "shell"; + + public static String getInputMethod(Context context) { + return kv().decodeString(KEY_INPUT_METHOD, METHOD_AUTO); + } + + public static void setInputMethod(Context context, String method) { + kv().encode(KEY_INPUT_METHOD, method == null ? METHOD_AUTO : method); + } +} diff --git a/app/src/main/java/com/ttstd/dialer/webrtc/utils/SignatureUtils.java b/app/src/main/java/com/ttstd/dialer/webrtc/utils/SignatureUtils.java new file mode 100644 index 0000000..5ec385b --- /dev/null +++ b/app/src/main/java/com/ttstd/dialer/webrtc/utils/SignatureUtils.java @@ -0,0 +1,113 @@ +package com.ttstd.dialer.webrtc.utils; + +import android.content.Context; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.os.Build; +import android.util.Log; + +import java.io.File; + +/** + * 签名与系统属性工具类(移植自 WebRTCControlled)。 + * 用于判断当前应用是否具备系统签名 / 系统共享 UID,从而选择触摸注入方式。 + */ +public class SignatureUtils { + + private static final String TAG = "SignatureUtils"; + + public static boolean isSystemApp(Context context) { + try { + ApplicationInfo ai = context.getPackageManager().getApplicationInfo(context.getPackageName(), 0); + return (ai.flags & ApplicationInfo.FLAG_SYSTEM) != 0 || + (ai.flags & ApplicationInfo.FLAG_UPDATED_SYSTEM_APP) != 0; + } catch (PackageManager.NameNotFoundException e) { + Log.e(TAG, "Package not found", e); + return false; + } + } + + public static boolean isSystemSignature(Context context) { + PackageManager pm = context.getPackageManager(); + try { + int result = pm.checkSignatures(context.getPackageName(), "android"); + return result == PackageManager.SIGNATURE_MATCH; + } catch (Exception e) { + Log.e(TAG, "Error checking system signature", e); + return false; + } + } + + public static boolean hasPermission(Context context, String permission) { + return context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED; + } + + public static boolean isSharedSystemUid(Context context) { + try { + PackageInfo pi = context.getPackageManager().getPackageInfo(context.getPackageName(), 0); + return "android.uid.system".equals(pi.sharedUserId); + } catch (PackageManager.NameNotFoundException e) { + Log.e(TAG, "Package not found", e); + return false; + } + } + + public static boolean isSystemUid() { + return android.os.Process.myUid() == 1000; + } + + public static boolean isDeviceRooted() { + String buildTags = Build.TAGS; + if (buildTags != null && buildTags.contains("test-keys")) { + return true; + } + String[] paths = { + "/system/app/Superuser.apk", + "/sbin/su", + "/system/bin/su", + "/system/xbin/su", + "/data/local/xbin/su", + "/data/local/bin/su", + "/system/sd/xbin/su", + "/system/bin/failsafe/su", + "/data/local/su", + "/su/bin/su" + }; + for (String path : paths) { + if (new File(path).exists()) { + return true; + } + } + return checkRootMethod3(); + } + + private static boolean checkRootMethod3() { + Process process = null; + try { + process = Runtime.getRuntime().exec(new String[]{"/system/xbin/which", "su"}); + return process.waitFor() == 0; + } catch (Throwable t) { + return false; + } finally { + if (process != null) process.destroy(); + } + } + + public static boolean isAppHasRootPermission() { + Process process = null; + try { + process = Runtime.getRuntime().exec("su"); + process.getOutputStream().write("exit\n".getBytes()); + process.getOutputStream().flush(); + int exitValue = process.waitFor(); + return exitValue == 0; + } catch (Exception e) { + return false; + } finally { + if (process != null) { + process.destroy(); + } + } + } +} diff --git a/app/src/main/java/com/ttstd/dialer/webrtc/webrtc/AppSdpObserver.java b/app/src/main/java/com/ttstd/dialer/webrtc/webrtc/AppSdpObserver.java new file mode 100644 index 0000000..1a60cff --- /dev/null +++ b/app/src/main/java/com/ttstd/dialer/webrtc/webrtc/AppSdpObserver.java @@ -0,0 +1,48 @@ +package com.ttstd.dialer.webrtc.webrtc; + +import android.util.Log; + +import org.webrtc.SdpObserver; +import org.webrtc.SessionDescription; + +public class AppSdpObserver implements SdpObserver { + + private static final String TAG = "AppSdpObserver"; + + private final SdpEventListener listener; + + public interface SdpEventListener { + void onCreateSuccess(SessionDescription sdp); + void onSetSuccess(); + void onCreateFailure(String error); + void onSetFailure(String error); + } + + public AppSdpObserver(SdpEventListener listener) { + this.listener = listener; + } + + @Override + public void onCreateSuccess(SessionDescription sdp) { + Log.d(TAG, "SDP created: " + sdp.type); + if (listener != null) listener.onCreateSuccess(sdp); + } + + @Override + public void onSetSuccess() { + Log.d(TAG, "SDP set success"); + if (listener != null) listener.onSetSuccess(); + } + + @Override + public void onCreateFailure(String error) { + Log.e(TAG, "SDP create failure: " + error); + if (listener != null) listener.onCreateFailure(error); + } + + @Override + public void onSetFailure(String error) { + Log.e(TAG, "SDP set failure: " + error); + if (listener != null) listener.onSetFailure(error); + } +} diff --git a/app/src/main/java/com/ttstd/dialer/webrtc/webrtc/PeerObserver.java b/app/src/main/java/com/ttstd/dialer/webrtc/webrtc/PeerObserver.java new file mode 100644 index 0000000..25f39d0 --- /dev/null +++ b/app/src/main/java/com/ttstd/dialer/webrtc/webrtc/PeerObserver.java @@ -0,0 +1,79 @@ +package com.ttstd.dialer.webrtc.webrtc; + +import android.util.Log; + +import org.webrtc.DataChannel; +import org.webrtc.IceCandidate; +import org.webrtc.MediaStream; +import org.webrtc.PeerConnection; +import org.webrtc.RtpReceiver; + +public class PeerObserver implements PeerConnection.Observer { + + private static final String TAG = "PeerObserver"; + + private final PeerEventListener listener; + + public interface PeerEventListener { + void onIceCandidate(IceCandidate candidate); + void onIceConnectionChange(PeerConnection.IceConnectionState newState); + void onDataChannel(DataChannel dataChannel); + void onAddStream(MediaStream stream); + void onRemoveStream(MediaStream stream); + } + + public PeerObserver(PeerEventListener listener) { + this.listener = listener; + } + + @Override + public void onSignalingChange(PeerConnection.SignalingState newState) { + Log.d(TAG, "Signaling state: " + newState); + } + + @Override + public void onIceConnectionChange(PeerConnection.IceConnectionState newState) { + Log.d(TAG, "ICE connection state: " + newState); + if (listener != null) listener.onIceConnectionChange(newState); + } + + @Override + public void onIceConnectionReceivingChange(boolean receiving) {} + + @Override + public void onIceGatheringChange(PeerConnection.IceGatheringState newState) { + Log.d(TAG, "ICE gathering state: " + newState); + } + + @Override + public void onIceCandidate(IceCandidate candidate) { + if (listener != null) listener.onIceCandidate(candidate); + } + + @Override + public void onIceCandidatesRemoved(IceCandidate[] candidates) {} + + @Override + public void onAddStream(MediaStream stream) { + if (listener != null) listener.onAddStream(stream); + } + + @Override + public void onRemoveStream(MediaStream stream) { + if (listener != null) listener.onRemoveStream(stream); + } + + @Override + public void onDataChannel(DataChannel dataChannel) { + Log.d(TAG, "Remote data channel received"); + if (listener != null) listener.onDataChannel(dataChannel); + } + + @Override + public void onRenegotiationNeeded() { + Log.d(TAG, "Renegotiation needed"); + } + + @Override + public void onAddTrack(RtpReceiver receiver, MediaStream[] streams) {} +} diff --git a/app/src/main/java/com/ttstd/dialer/webrtc/webrtc/SelfCodecEncoder.java b/app/src/main/java/com/ttstd/dialer/webrtc/webrtc/SelfCodecEncoder.java new file mode 100644 index 0000000..2962f30 --- /dev/null +++ b/app/src/main/java/com/ttstd/dialer/webrtc/webrtc/SelfCodecEncoder.java @@ -0,0 +1,469 @@ +package com.ttstd.dialer.webrtc.webrtc; + +import android.media.Image; +import android.media.MediaCodec; +import android.media.MediaCodecInfo; +import android.media.MediaCodecList; +import android.media.MediaFormat; +import android.os.Bundle; +import android.util.Log; + +import org.webrtc.DataChannel; +import org.webrtc.VideoFrame; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.ArrayDeque; + +/** + * 自建屏幕编码通道:复用 WebRTC 的屏幕采集管线(ScreenCapturerAndroid 的 VideoFrame), + * 把每一帧编码为 H.264 裸流后,经 WebRTC 的 video DataChannel 分片透传给控制端 + * (控制端使用自建 MediaCodec 解码渲染)。 + * + * 设计要点(Android 14 兼容): + * 在 Android 14+ 上,MediaProjection 授权令牌为一次性,且一个 MediaProjection 实例 + * 只能 createVirtualDisplay 一次。因此自编码模式【不再】自己申请 MediaProjection / + * VirtualDisplay,而是复用 WebRTC 已经建立的、唯一的屏幕采集管线,从 VideoFrame 直接编码。 + * 这样全程只有一个虚拟显示、一个令牌。 + */ +public class SelfCodecEncoder { + + public static final int STREAM_MODE_WEBRTC = 0; // WebRTC 内置媒体流 + public static final int STREAM_MODE_SELF_CODEC = 1; // 自编码(本类) + + // ---- 自定义二进制协议(与控制端 SelfCodecDecoder 保持一致)---- + private static final byte MAGIC = (byte) 0xAB; + private static final int CHUNK_SIZE = 16384; // 每个 DataChannel 分片大小 + private static final byte UNIT_TYPE_CONFIG = 1; // SPS/PPS 等参数集 + private static final byte UNIT_TYPE_FRAME = 2; // 编码帧 + + private static final String TAG = "SelfCodecEncoder"; + + private final int width; + private final int height; + private final int fps; + private final DataChannel videoChannel; + private final Callback callback; + + private MediaCodec encoder; + private boolean running = false; + private int frameSeq = 0; + private final Object sendLock = new Object(); + private final Object inputLock = new Object(); + private final ArrayDeque freeInputIndices = new ArrayDeque<>(); + private byte[] nv12Buffer; + // 缓存 SPS/PPS 等参数集:video 通道不可靠(可能丢包),在每个关键帧前重发, + // 保证控制端即使错过首次 CONFIG 也能在下一个 IDR 前完成解码器配置。 + private final java.util.ArrayList cachedConfigs = new java.util.ArrayList<>(); + + public interface Callback { + void onEncoderStarted(int w, int h, int fps); + + void onEncoderError(String error); + } + + public SelfCodecEncoder(int width, int height, int fps, DataChannel videoChannel, Callback cb) { + this.width = width; + this.height = height; + this.fps = fps; + this.videoChannel = videoChannel; + this.callback = cb; + } + + public void start() { + if (running) return; + try { + MediaFormat format = MediaFormat.createVideoFormat(MediaFormat.MIMETYPE_VIDEO_AVC, width, height); + int bitrate = computeBitrate(width, height, fps); + format.setInteger(MediaFormat.KEY_BIT_RATE, bitrate); + format.setInteger(MediaFormat.KEY_FRAME_RATE, fps); + format.setInteger(MediaFormat.KEY_CAPTURE_RATE, fps); + format.setInteger(MediaFormat.KEY_COLOR_FORMAT, selectColorFormat()); + format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1); + + encoder = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_VIDEO_AVC); + encoder.setCallback(encoderCallback); + encoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE); + encoder.start(); + + nv12Buffer = new byte[width * height * 3 / 2]; + running = true; + if (callback != null) callback.onEncoderStarted(width, height, fps); + Log.i(TAG, "self codec encoder started: " + width + "x" + height + "@" + fps + ", bitrate=" + bitrate); + } catch (Exception e) { + Log.e(TAG, "start encoder failed", e); + release(); + if (callback != null) callback.onEncoderError(e.getMessage()); + } + } + + private int selectColorFormat() { + try { + MediaCodecList list = new MediaCodecList(MediaCodecList.REGULAR_CODECS); + MediaCodecInfo info = null; + for (MediaCodecInfo c : list.getCodecInfos()) { + if (!c.isEncoder()) continue; + for (String t : c.getSupportedTypes()) { + if (t.equalsIgnoreCase(MediaFormat.MIMETYPE_VIDEO_AVC)) { + info = c; + break; + } + } + if (info != null) break; + } + if (info == null) return MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420SemiPlanar; + MediaCodecInfo.CodecCapabilities caps = info.getCapabilitiesForType(MediaFormat.MIMETYPE_VIDEO_AVC); + for (int f : caps.colorFormats) { + if (f == MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420SemiPlanar) { + return f; + } + } + for (int f : caps.colorFormats) { + if (f == MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Flexible) { + return f; + } + } + return MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420SemiPlanar; + } catch (Exception e) { + return MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420SemiPlanar; + } + } + + private int computeBitrate(int w, int h, int fps) { + int b = (int) (w * h * fps * 0.12); + return Math.max(500_000, Math.min(8_000_000, b)); + } + + /** + * 由 WebRTC 采集管线喂入一帧(共享同一条屏幕采集,无需再申请 VirtualDisplay)。 + * 当编码器输入缓冲不足时会丢弃该帧(编码器跟不上),保证不堆积、不阻塞采集线程。 + */ + public void feedFrame(VideoFrame frame) { + synchronized (this) { + MediaCodec mc = encoder; + if (!running || mc == null) return; + + if (frame.getRotatedWidth() != width || frame.getRotatedHeight() != height) { + return; + } + + Integer idx; + synchronized (inputLock) { + idx = freeInputIndices.poll(); + } + if (idx == null) { + return; + } + try { + encodeFrame(mc, idx, frame); + } catch (Exception e) { + Log.e(TAG, "encodeFrame failed", e); + try { + mc.queueInputBuffer(idx, 0, 0, System.nanoTime() / 1000, 0); + } catch (Exception ignored) { + } + } + } + } + + private void encodeFrame(MediaCodec mc, int index, VideoFrame frame) { + long ptsUs = System.nanoTime() / 1000; + VideoFrame.I420Buffer i420 = frame.getBuffer().toI420(); + if (i420 == null) { + mc.queueInputBuffer(index, 0, 0, ptsUs, 0); + return; + } + int w = i420.getWidth(); + int h = i420.getHeight(); + byte[] buffer = ensureNv12Buffer(w, h); + i420ToNv12(i420, buffer, w, h); + i420.release(); + writeNv12(mc, index, buffer, w, h, ptsUs); + } + + private byte[] ensureNv12Buffer(int w, int h) { + int need = w * h * 3 / 2; + byte[] buf = nv12Buffer; + if (buf == null || buf.length < need) { + buf = new byte[need]; + nv12Buffer = buf; + } + return buf; + } + + /** I420(三平面)→ NV12(Y + UV 交错),写入 out。 */ + private void i420ToNv12(VideoFrame.I420Buffer i420, byte[] out, int w, int h) { + ByteBuffer y = i420.getDataY(); + ByteBuffer u = i420.getDataU(); + ByteBuffer v = i420.getDataV(); + int yStride = i420.getStrideY(); + int uStride = i420.getStrideU(); + int vStride = i420.getStrideV(); + int off = 0; + for (int r = 0; r < h; r++) { + y.position(r * yStride); + y.get(out, off, w); + off += w; + } + int cw = w / 2; + int ch = h / 2; + for (int r = 0; r < ch; r++) { + for (int c = 0; c < cw; c++) { + out[off++] = u.get(r * uStride + c); + out[off++] = v.get(r * vStride + c); + } + } + } + + /** 把 NV12 按编码器输入 Image 的 stride 写入输入缓冲并入队。 */ + private void writeNv12(MediaCodec mc, int index, byte[] nv12, int w, int h, long ptsUs) { + try (Image image = mc.getInputImage(index)) { + if (image == null) { + mc.queueInputBuffer(index, 0, 0, ptsUs, 0); + return; + } + Image.Plane[] planes = image.getPlanes(); + Image.Plane yPlane = planes[0]; + ByteBuffer yBuf = yPlane.getBuffer(); + int yRow = yPlane.getRowStride(); + int yPix = yPlane.getPixelStride(); + int yLimit = yBuf.limit(); + + int src = 0; + for (int r = 0; r < h; r++) { + int dstRow = r * yRow; + for (int c = 0; c < w; c++) { + int dst = dstRow + c * yPix; + if (dst < yLimit) { + yBuf.put(dst, nv12[src + c]); + } + } + src += w; + } + + int ch = h / 2; + int cw = w / 2; + int uvSrcBase = w * h; + + if (planes.length >= 3 && planes[1].getPixelStride() == 1 && planes[2].getPixelStride() == 1) { + for (int p = 1; p <= 2; p++) { + Image.Plane plane = planes[p]; + ByteBuffer buf = plane.getBuffer(); + int row = plane.getRowStride(); + int limit = buf.limit(); + for (int r = 0; r < ch; r++) { + int dstRow = r * row; + for (int c = 0; c < cw; c++) { + int s = uvSrcBase + (r * cw + c) * 2 + (p - 1); + int dst = dstRow + c; + if (dst < limit) { + buf.put(dst, nv12[s]); + } + } + } + } + } else if (planes.length >= 2) { + Image.Plane uvPlane = planes[1]; + ByteBuffer uvBuf = uvPlane.getBuffer(); + int uvRow = uvPlane.getRowStride(); + int uvPix = uvPlane.getPixelStride(); + int uvLimit = uvBuf.limit(); + + for (int r = 0; r < ch; r++) { + int dRow = r * uvRow; + int sRow = uvSrcBase + r * cw * 2; + for (int c = 0; c < cw; c++) { + int s = sRow + c * 2; + int d = dRow + c * uvPix; + if (d < uvLimit) { + uvBuf.put(d, nv12[s]); + } + if (d + 1 < uvLimit) { + uvBuf.put(d + 1, nv12[s + 1]); + } + } + } + } + image.close(); + mc.queueInputBuffer(index, 0, w * h * 3 / 2, ptsUs, 0); + } catch (Exception e) { + Log.e(TAG, "writeNv12 failed", e); + try { + mc.queueInputBuffer(index, 0, 0, ptsUs, 0); + } catch (Exception ignored) { + } + } + } + + private final MediaCodec.Callback encoderCallback = new MediaCodec.Callback() { + @Override + public void onInputBufferAvailable(MediaCodec mc, int index) { + synchronized (inputLock) { + freeInputIndices.add(index); + } + } + + @Override + public void onOutputFormatChanged(MediaCodec mc, MediaFormat format) { + sendCsds(format); + } + + @Override + public void onOutputBufferAvailable(MediaCodec mc, int index, MediaCodec.BufferInfo info) { + try { + ByteBuffer buf = mc.getOutputBuffer(index); + if (buf == null) { + mc.releaseOutputBuffer(index, false); + return; + } + byte[] data = new byte[info.size]; + buf.position(info.offset); + buf.get(data); + boolean isKey = (info.flags & MediaCodec.BUFFER_FLAG_KEY_FRAME) != 0; + boolean isConfig = (info.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != 0; + long pts = info.presentationTimeUs; + mc.releaseOutputBuffer(index, false); + + if (isConfig) { + cacheConfig(data); + sendUnit(UNIT_TYPE_CONFIG, 0, false, data); + } else { + if (isKey) { + resendCachedConfigs(); + } + sendUnit(UNIT_TYPE_FRAME, pts, isKey, data); + } + } catch (Exception e) { + Log.e(TAG, "onOutputBufferAvailable error", e); + } + } + + @Override + public void onError(MediaCodec mc, MediaCodec.CodecException e) { + Log.e(TAG, "encoder error", e); + if (callback != null) callback.onEncoderError(e.getMessage()); + } + }; + + private void sendCsds(MediaFormat format) { + if (format == null) return; + ByteBuffer sps = format.getByteBuffer("csd-0"); + ByteBuffer pps = format.getByteBuffer("csd-1"); + if (sps != null) { + byte[] d = toBytes(sps); + cacheConfig(d); + sendUnit(UNIT_TYPE_CONFIG, 0, false, d); + } + if (pps != null) { + byte[] d = toBytes(pps); + cacheConfig(d); + sendUnit(UNIT_TYPE_CONFIG, 0, false, d); + } + } + + private void cacheConfig(byte[] data) { + if (data == null || data.length == 0) return; + synchronized (cachedConfigs) { + for (byte[] c : cachedConfigs) { + if (java.util.Arrays.equals(c, data)) return; + } + cachedConfigs.add(data); + } + } + + private void resendCachedConfigs() { + byte[][] snapshot; + synchronized (cachedConfigs) { + if (cachedConfigs.isEmpty()) return; + snapshot = cachedConfigs.toArray(new byte[0][]); + } + for (byte[] c : snapshot) { + sendUnit(UNIT_TYPE_CONFIG, 0, false, c); + } + } + + private static byte[] toBytes(ByteBuffer b) { + ByteBuffer d = b.duplicate(); + byte[] out = new byte[d.remaining()]; + d.get(out); + return out; + } + + private void sendUnit(byte unitType, long pts, boolean isKey, byte[] data) { + ByteBuffer unit = ByteBuffer.allocate(1 + 4 + 1 + 4 + data.length).order(ByteOrder.BIG_ENDIAN); + unit.put(unitType); + unit.putInt((int) (pts & 0xFFFFFFFFL)); + unit.put((byte) (isKey ? 1 : 0)); + unit.putInt(data.length); + unit.put(data); + byte[] unitBytes = unit.array(); + + int totalChunks = (unitBytes.length + CHUNK_SIZE - 1) / CHUNK_SIZE; + if (totalChunks == 0) totalChunks = 1; + int seq; + synchronized (sendLock) { + seq = frameSeq++; + } + for (int i = 0; i < totalChunks; i++) { + int off = i * CHUNK_SIZE; + int len = Math.min(CHUNK_SIZE, unitBytes.length - off); + byte[] chunk = new byte[len]; + System.arraycopy(unitBytes, off, chunk, 0, len); + sendChunk(seq, (short) totalChunks, (short) i, chunk); + } + } + + private void sendChunk(int seq, short total, short idx, byte[] chunk) { + if (videoChannel == null || videoChannel.state() != DataChannel.State.OPEN) return; + ByteBuffer msg = ByteBuffer.allocate(1 + 4 + 2 + 2 + 4 + chunk.length).order(ByteOrder.BIG_ENDIAN); + msg.put(MAGIC); + msg.putInt(seq); + msg.putShort(total); + msg.putShort(idx); + msg.putInt(chunk.length); + msg.put(chunk); + msg.flip(); + try { + videoChannel.send(new DataChannel.Buffer(msg, true)); + } catch (Exception e) { + Log.e(TAG, "send video chunk failed", e); + } + } + + /** 即时请求一个关键帧(强求 IDR)。 */ + public void requestKeyFrame() { + MediaCodec mc = encoder; + if (mc == null) return; + try { + Bundle params = new Bundle(); + params.putInt(MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME, 0); + mc.setParameters(params); + } catch (Exception e) { + Log.w(TAG, "requestKeyFrame failed", e); + } + } + + /** 返回编码器是否正在运行(供上层判断启动结果)。 */ + public boolean isRunning() { + return running; + } + + public void release() { + synchronized (this) { + running = false; + freeInputIndices.clear(); + if (encoder != null) { + try { + encoder.stop(); + } catch (Exception ignored) { + } + try { + encoder.release(); + } catch (Exception ignored) { + } + encoder = null; + } + nv12Buffer = null; + } + } +} diff --git a/app/src/main/java/com/ttstd/dialer/webrtc/webrtc/WebRtcClient.java b/app/src/main/java/com/ttstd/dialer/webrtc/webrtc/WebRtcClient.java new file mode 100644 index 0000000..faee2dd --- /dev/null +++ b/app/src/main/java/com/ttstd/dialer/webrtc/webrtc/WebRtcClient.java @@ -0,0 +1,635 @@ +package com.ttstd.dialer.webrtc.webrtc; + +import android.content.Context; +import android.util.Log; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonObject; +import com.ttstd.control.Action; +import com.ttstd.control.ControlMessage; +import com.ttstd.dialer.webrtc.signaling.SignalMessage; +import com.ttstd.dialer.webrtc.signaling.WebSocketClient; + +import org.webrtc.DataChannel; +import org.webrtc.DefaultVideoDecoderFactory; +import org.webrtc.DefaultVideoEncoderFactory; +import org.webrtc.EglBase; +import org.webrtc.IceCandidate; +import org.webrtc.MediaConstraints; +import org.webrtc.MediaStream; +import org.webrtc.PeerConnection; +import org.webrtc.PeerConnectionFactory; +import org.webrtc.SessionDescription; +import org.webrtc.SurfaceTextureHelper; +import org.webrtc.VideoCapturer; +import org.webrtc.VideoCodecInfo; +import org.webrtc.VideoSink; +import org.webrtc.VideoSource; +import org.webrtc.VideoTrack; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public class WebRtcClient { + + private static final String TAG = "WebRtcClient"; + private static final String VIDEO_TRACK_ID = "screen_track"; + private static final String STREAM_ID = "screen_stream"; + private static final String DATA_CHANNEL_LABEL = "control_channel"; + private static final String VIDEO_CHANNEL_LABEL = "video_channel"; // 自编码视频透传通道(控制端创建) + + // 码率优化参数(单位 kbps)。 + private static final int MIN_BITRATE_KBPS = 1000; // 1 Mbps + private static final int START_BITRATE_KBPS = 2000; // 2 Mbps + private static final int MAX_BITRATE_KBPS = 8000; // 8 Mbps + + private final Context context; + private final WebSocketClient wsClient; + private final String deviceId; + private final Gson gson = new GsonBuilder().disableHtmlEscaping().create(); + + private PeerConnectionFactory peerConnectionFactory; + private PeerConnection peerConnection; + private DataChannel dataChannel; + private DataChannel videoDataChannel; // 自编码视频透传(控制端创建的 video_channel) + private VideoSource videoSource; + private VideoTrack videoTrack; + private org.webrtc.RtpSender videoSender; + private EglBase eglBase; + private String currentControllerId; + + private VideoCapturer videoCapturer; + private int videoWidth; + private int videoHeight; + private int videoFps; + + private InputCommandCallback inputCallback; + + /** 自编码模式帧监听器:WebRTC 采集到的每一帧都会转发给自编码编码器(共享同一条屏幕采集)。 */ + public interface SelfCodecFrameListener { + void onFrame(org.webrtc.VideoFrame frame); + } + + private SelfCodecFrameListener selfCodecFrameListener; + + private RemoteDisconnectCallback remoteDisconnectCallback; + private boolean remoteDisconnectNotified = false; + private boolean suppressDisconnectNotify = false; + + public interface IceEventListener { + void onIceConnected(); + } + + private IceEventListener iceEventListener; + + private boolean remoteDescriptionSet = false; + private final List pendingRemoteCandidates = new ArrayList<>(); + + public interface InputCommandCallback { + void onCommandReceived(byte[] data); + } + + public interface RemoteDisconnectCallback { + void onRemoteDisconnected(String controllerId); + } + + public WebRtcClient(Context context, WebSocketClient wsClient, String deviceId) { + this.context = context; + this.wsClient = wsClient; + this.deviceId = deviceId; + } + + public void setInputCallback(InputCommandCallback callback) { + this.inputCallback = callback; + } + + public void setRemoteDisconnectCallback(RemoteDisconnectCallback callback) { + this.remoteDisconnectCallback = callback; + } + + public void setIceEventListener(IceEventListener listener) { + this.iceEventListener = listener; + } + + private void notifyRemoteDisconnected() { + if (remoteDisconnectNotified) return; + if (suppressDisconnectNotify) return; + remoteDisconnectNotified = true; + if (remoteDisconnectCallback != null) { + remoteDisconnectCallback.onRemoteDisconnected(currentControllerId); + } + } + + public void initialize(EglBase eglBase) { + this.eglBase = eglBase; + PeerConnectionFactory.InitializationOptions initOptions = + PeerConnectionFactory.InitializationOptions.builder(context) + .setFieldTrials("WebRTC-H264HighProfile/Enabled/WebRTC-Video-HwAcceleration/Enabled/WebRTC-FlexFEC-03/Enabled/") + .createInitializationOptions(); + PeerConnectionFactory.initialize(initOptions); + + // 与源 WebRTCControlled 一致:真机优先硬件编码(VP8/VP9 硬编,性能好、省电)。 + // 设备端为真机,硬件编码器可用;模拟器场景可临时切回软件编码。 + DefaultVideoEncoderFactory encoderFactory = new DefaultVideoEncoderFactory(eglBase.getEglBaseContext(), true, false); + DefaultVideoDecoderFactory decoderFactory = new DefaultVideoDecoderFactory(eglBase.getEglBaseContext()); + + for (VideoCodecInfo info : encoderFactory.getSupportedCodecs()) { + Log.i(TAG, " - Codec: " + info.name + ", params: " + info.params); + } + + PeerConnectionFactory.Options options = new PeerConnectionFactory.Options(); + peerConnectionFactory = PeerConnectionFactory.builder() + .setOptions(options) + .setVideoEncoderFactory(encoderFactory) + .setVideoDecoderFactory(decoderFactory) + .createPeerConnectionFactory(); + } + + public void setVideoCapturer(VideoCapturer capturer, int width, int height, int fps) { + this.videoCapturer = capturer; + this.videoWidth = width; + this.videoHeight = height; + this.videoFps = fps; + + SurfaceTextureHelper surfaceTextureHelper = SurfaceTextureHelper.create("CaptureThread", eglBase.getEglBaseContext()); + videoSource = peerConnectionFactory.createVideoSource(true); + capturer.initialize(surfaceTextureHelper, context, videoSource.getCapturerObserver()); + capturer.startCapture(width, height, fps); + + videoTrack = peerConnectionFactory.createVideoTrack(VIDEO_TRACK_ID, videoSource); + if (localSink != null) { + videoTrack.addSink(localSink); + } + videoTrack.addSink(new VideoSink() { + @Override + public void onFrame(org.webrtc.VideoFrame frame) { + if (selfCodecFrameListener != null) { + selfCodecFrameListener.onFrame(frame); + } + } + }); + } + + public void changeCaptureFormat(int width, int height, int fps) { + if (videoCapturer == null) { + Log.w(TAG, "changeCaptureFormat ignored: capturer not ready"); + return; + } + int targetFps = fps > 0 ? fps : this.videoFps; + if (targetFps <= 0) targetFps = 30; + Log.i(TAG, "Changing capture format -> " + width + "x" + height + " @ " + targetFps + "fps"); + try { + videoCapturer.changeCaptureFormat(width, height, targetFps); + } catch (Exception e) { + Log.e(TAG, "changeCaptureFormat failed", e); + return; + } + this.videoWidth = width; + this.videoHeight = height; + this.videoFps = targetFps; + triggerKeyFrame(); + sendResolutionReport(width, height, targetFps); + } + + private List supportedFpsList; + + public void setSupportedFpsList(List fpsList) { + this.supportedFpsList = fpsList; + } + + public void sendResolutionReport(int width, int height, int fps) { + if (dataChannel == null || dataChannel.state() != DataChannel.State.OPEN) { + Log.d(TAG, "sendResolutionReport skipped: dataChannel not open"); + return; + } + try { + ControlMessage.Builder builder = ControlMessage.newBuilder() + .setAction(Action.REPORT_RESOLUTION) + .setWidth(width) + .setHeight(height) + .setFps(fps); + if (supportedFpsList != null && !supportedFpsList.isEmpty()) { + builder.addAllSupportedFps(supportedFpsList); + } + ControlMessage report = builder.build(); + ByteBuffer buffer = ByteBuffer.wrap(report.toByteArray()); + dataChannel.send(new DataChannel.Buffer(buffer, true)); + Log.i(TAG, "Reported capture resolution -> " + width + "x" + height + " @ " + fps + "fps"); + } catch (Exception e) { + Log.e(TAG, "sendResolutionReport failed", e); + } + } + + public int[] getCurrentResolution() { + return new int[]{ videoWidth, videoHeight, videoFps }; + } + + private VideoSink localSink; + + public void setLocalSink(VideoSink sink) { + this.localSink = sink; + if (videoTrack != null && sink != null) { + videoTrack.addSink(sink); + } + } + + public EglBase.Context getEglContext() { + return eglBase != null ? eglBase.getEglBaseContext() : null; + } + + public void createPeerConnectionAndAnswer(String offerSdp, String controllerId) { + if (peerConnection != null) { + Log.d(TAG, "Closing previous peer connection"); + suppressDisconnectNotify = true; + closeCurrentConnection(); + suppressDisconnectNotify = false; + } + this.currentControllerId = controllerId; + this.remoteDisconnectNotified = false; + this.remoteDescriptionSet = false; + this.pendingRemoteCandidates.clear(); + + List iceServers = new ArrayList<>(); + // ICE 服务器(与 webrtc_controller_flutter 的 ice_servers.dart 完全一致,含 STUN + TURN)。 + iceServers.add(PeerConnection.IceServer.builder("stun:stun.l.google.com:19302").createIceServer()); + iceServers.add(PeerConnection.IceServer.builder("stun:175.178.213.60:3478").createIceServer()); + iceServers.add(PeerConnection.IceServer.builder("turns:175.178.213.60:5349").setUsername("fanhuitong").setPassword("Fan19961907..").createIceServer()); + iceServers.add(PeerConnection.IceServer.builder("turn:175.178.213.60:3478?transport=tcp").setUsername("fanhuitong").setPassword("Fan19961907..").createIceServer()); + iceServers.add(PeerConnection.IceServer.builder("turn:175.178.213.60:3478").setUsername("fanhuitong").setPassword("Fan19961907..").createIceServer()); + iceServers.add(PeerConnection.IceServer.builder("stun:47.242.112.133:3478").createIceServer()); + iceServers.add(PeerConnection.IceServer.builder("turn:47.242.112.133:3478").setUsername("ttstd").setPassword("fanhuitong").createIceServer()); + iceServers.add(PeerConnection.IceServer.builder("stun:192.168.5.224:3478").createIceServer()); + iceServers.add(PeerConnection.IceServer.builder("turn:192.168.5.224:3478").setUsername("tt").setPassword("fht").createIceServer()); + + PeerConnection.RTCConfiguration config = new PeerConnection.RTCConfiguration(iceServers); + config.sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN; + config.continualGatheringPolicy = PeerConnection.ContinualGatheringPolicy.GATHER_CONTINUALLY; + config.iceCandidatePoolSize = 10; + config.tcpCandidatePolicy = PeerConnection.TcpCandidatePolicy.ENABLED; + + PeerObserver peerObserver = new PeerObserver(new PeerObserver.PeerEventListener() { + @Override + public void onIceCandidate(IceCandidate candidate) { + sendIceCandidate(candidate, controllerId); + } + + @Override + public void onIceConnectionChange(PeerConnection.IceConnectionState newState) { + Log.d(TAG, "ICE connection state: " + newState); + if (newState == PeerConnection.IceConnectionState.CONNECTED) { + triggerKeyFrame(); + if (iceEventListener != null) iceEventListener.onIceConnected(); + } else if (newState == PeerConnection.IceConnectionState.DISCONNECTED + || newState == PeerConnection.IceConnectionState.FAILED + || newState == PeerConnection.IceConnectionState.CLOSED) { + notifyRemoteDisconnected(); + } + } + + @Override + public void onDataChannel(DataChannel dc) { + Log.d(TAG, "Data channel received: " + dc.label()); + handleDataChannel(dc); + } + + @Override + public void onAddStream(MediaStream stream) { + } + + @Override + public void onRemoveStream(MediaStream stream) { + } + }); + + peerConnection = peerConnectionFactory.createPeerConnection(config, peerObserver); + + if (videoTrack != null) { + videoSender = peerConnection.addTrack(videoTrack, Collections.singletonList(STREAM_ID)); + } + + SessionDescription remoteSdp = new SessionDescription(SessionDescription.Type.OFFER, offerSdp); + peerConnection.setRemoteDescription(new AppSdpObserver(new AppSdpObserver.SdpEventListener() { + @Override + public void onCreateSuccess(SessionDescription sdp) { + } + + @Override + public void onSetSuccess() { + remoteDescriptionSet = true; + flushPendingCandidates(); + MediaConstraints constraints = new MediaConstraints(); + peerConnection.createAnswer(new AppSdpObserver(new AppSdpObserver.SdpEventListener() { + @Override + public void onCreateSuccess(SessionDescription sdp) { + String modifiedSdpDescription = optimizeSdp(sdp.description); + SessionDescription modifiedSdp = new SessionDescription(sdp.type, modifiedSdpDescription); + + peerConnection.setLocalDescription(new AppSdpObserver(new AppSdpObserver.SdpEventListener() { + @Override + public void onCreateSuccess(SessionDescription s) { + } + + @Override + public void onSetSuccess() { + Log.d(TAG, "onSetSuccess: modifiedSdp = " + modifiedSdpDescription); + sendAnswer(modifiedSdpDescription, controllerId); + } + + @Override + public void onCreateFailure(String error) { + Log.e(TAG, "setLocalDescription onCreateFailure: " + error); + } + + @Override + public void onSetFailure(String error) { + Log.e(TAG, "setLocalDescription onSetFailure: " + error); + } + }), modifiedSdp); + } + + @Override + public void onSetSuccess() { + } + + @Override + public void onCreateFailure(String error) { + Log.e(TAG, "createAnswer onCreateFailure: " + error); + } + + @Override + public void onSetFailure(String error) { + } + }), constraints); + } + + @Override + public void onCreateFailure(String error) { + } + + @Override + public void onSetFailure(String error) { + Log.e(TAG, "setRemoteDescription onSetFailure: " + error); + } + }), remoteSdp); + } + + public void addIceCandidate(IceCandidate candidate) { + if (peerConnection == null || !remoteDescriptionSet) { + pendingRemoteCandidates.add(candidate); + return; + } + if (peerConnection != null) { + peerConnection.addIceCandidate(candidate); + } + } + + private void flushPendingCandidates() { + if (peerConnection == null) return; + for (IceCandidate c : pendingRemoteCandidates) { + peerConnection.addIceCandidate(c); + } + pendingRemoteCandidates.clear(); + } + + public void sendInputResponse(String responseJson) { + if (dataChannel != null && dataChannel.state() == DataChannel.State.OPEN) { + DataChannel.Buffer buffer = new DataChannel.Buffer( + ByteBuffer.wrap(responseJson.getBytes(StandardCharsets.UTF_8)), false); + dataChannel.send(buffer); + } + } + + public void close() { + closeCurrentConnection(); + if (peerConnectionFactory != null) { + peerConnectionFactory.dispose(); + peerConnectionFactory = null; + } + } + + private void closeCurrentConnection() { + remoteDescriptionSet = false; + pendingRemoteCandidates.clear(); + if (dataChannel != null) { + try { + dataChannel.unregisterObserver(); + } catch (Exception e) { + } + dataChannel.close(); + dataChannel.dispose(); + dataChannel = null; + } + if (videoDataChannel != null) { + try { + videoDataChannel.close(); + } catch (Exception e) { + } + try { + videoDataChannel.dispose(); + } catch (Exception e) { + } + videoDataChannel = null; + } + if (peerConnection != null) { + peerConnection.close(); + peerConnection.dispose(); + peerConnection = null; + } + } + + public DataChannel getVideoDataChannel() { + return videoDataChannel; + } + + public boolean isVideoDataChannelOpen() { + return videoDataChannel != null && videoDataChannel.state() == DataChannel.State.OPEN; + } + + public void setSelfCodecFrameListener(SelfCodecFrameListener listener) { + this.selfCodecFrameListener = listener; + } + + public void stopCapture() { + if (videoCapturer != null) { + try { + videoCapturer.stopCapture(); + } catch (InterruptedException e) { + Log.w(TAG, "stopCapture interrupted", e); + } + } + } + + public void startCapture() { + if (videoCapturer != null) { + try { + videoCapturer.startCapture(videoWidth, videoHeight, videoFps); + } catch (RuntimeException e) { + Log.e(TAG, "startCapture failed", e); + } + } + } + + public void sendControlCommand(ControlMessage message) { + if (dataChannel == null || dataChannel.state() != DataChannel.State.OPEN) { + Log.d(TAG, "sendControlCommand skipped: dataChannel not open"); + return; + } + try { + ByteBuffer buffer = ByteBuffer.wrap(message.toByteArray()); + dataChannel.send(new DataChannel.Buffer(buffer, true)); + } catch (Exception e) { + Log.e(TAG, "sendControlCommand failed", e); + } + } + + private void handleDataChannel(DataChannel dc) { + if (VIDEO_CHANNEL_LABEL.equals(dc.label())) { + this.videoDataChannel = dc; + Log.d(TAG, "Video data channel received: " + dc.label()); + return; + } + this.dataChannel = dc; + dc.registerObserver(new DataChannel.Observer() { + @Override + public void onBufferedAmountChange(long previousAmount) { + } + + @Override + public void onStateChange() { + Log.d(TAG, "DataChannel state: " + dc.state()); + if (dc.state() == DataChannel.State.OPEN) { + sendResolutionReport(videoWidth, videoHeight, videoFps); + } else if (dc.state() == DataChannel.State.CLOSED) { + notifyRemoteDisconnected(); + } + } + + @Override + public void onMessage(DataChannel.Buffer buffer) { + byte[] data = new byte[buffer.data.remaining()]; + buffer.data.get(data); + Log.d(TAG, "DataChannel message received, bytes=" + data.length); + if (inputCallback != null) { + inputCallback.onCommandReceived(data); + } + } + }); + } + + private String optimizeSdp(String sdp) { + String[] lines = sdp.split("\n"); + StringBuilder newSdp = new StringBuilder(); + + List vp8 = new ArrayList<>(); + List vp9 = new ArrayList<>(); + List h264 = new ArrayList<>(); + for (String line : lines) { + String trimmedLine = line.trim(); + if (trimmedLine.startsWith("a=rtpmap:")) { + String payload = trimmedLine.split(":")[1].split(" ")[0]; + if (trimmedLine.contains("VP8/90000")) { + vp8.add(payload); + } else if (trimmedLine.contains("VP9/90000")) { + vp9.add(payload); + } else if (trimmedLine.contains("H264/90000")) { + h264.add(payload); + } + } + } + + List videoPayloads = new ArrayList<>(); + videoPayloads.addAll(vp8); + videoPayloads.addAll(vp9); + videoPayloads.addAll(h264); + + if (videoPayloads.isEmpty()) { + return sdp; + } + + for (String line : lines) { + String trimmedLine = line.trim(); + if (trimmedLine.isEmpty()) continue; + + if (trimmedLine.startsWith("m=video")) { + String[] parts = trimmedLine.split(" "); + if (parts.length > 3) { + StringBuilder mLine = new StringBuilder(parts[0] + " " + parts[1] + " " + parts[2]); + for (String payload : videoPayloads) { + mLine.append(" ").append(payload); + } + for (int i = 3; i < parts.length; i++) { + if (!videoPayloads.contains(parts[i])) { + mLine.append(" ").append(parts[i]); + } + } + newSdp.append(mLine).append("\r\n"); + } else { + newSdp.append(trimmedLine).append("\r\n"); + } + } else if (trimmedLine.startsWith("a=fmtp:")) { + if (videoPayloads.contains(trimmedLine.split(":")[1].split(" ")[0])) { + String bonus = ";x-google-start-bitrate=" + START_BITRATE_KBPS + + ";x-google-max-bitrate=" + MAX_BITRATE_KBPS + + ";x-google-min-bitrate=" + MIN_BITRATE_KBPS; + newSdp.append(trimmedLine).append(bonus).append("\r\n"); + } else { + newSdp.append(trimmedLine).append("\r\n"); + } + } else { + newSdp.append(trimmedLine).append("\r\n"); + } + } + return newSdp.toString(); + } + + private void sendAnswer(String sdp, String controllerId) { + SignalMessage msg = new SignalMessage(); + msg.setType("ANSWER"); + msg.setFromDeviceId(deviceId); + msg.setToDeviceId(controllerId); + msg.setDeviceType("CONTROLLED"); + + JsonObject payload = new JsonObject(); + payload.addProperty("sdp", sdp); + msg.setPayload(payload.toString()); + + wsClient.sendMessage(msg); + } + + private void sendIceCandidate(IceCandidate candidate, String controllerId) { + SignalMessage msg = new SignalMessage(); + msg.setType("ICE_CANDIDATE"); + msg.setFromDeviceId(deviceId); + msg.setToDeviceId(controllerId); + msg.setDeviceType("CONTROLLED"); + + JsonObject payload = new JsonObject(); + payload.addProperty("sdpMid", candidate.sdpMid); + payload.addProperty("sdpMLineIndex", candidate.sdpMLineIndex); + payload.addProperty("candidate", candidate.sdp); + msg.setPayload(payload.toString()); + + wsClient.sendMessage(msg); + } + + public void triggerKeyFrame() { + if (videoSender != null) { + Log.d(TAG, "Triggering key frame via RtpSender parameters update"); + org.webrtc.RtpParameters parameters = videoSender.getParameters(); + if (parameters != null && !parameters.encodings.isEmpty()) { + videoSender.setParameters(parameters); + } + } + } + + public void requestSelfCodecKeyFrame(SelfCodecEncoder encoder) { + if (encoder != null) { + Log.d(TAG, "Requesting key frame for self codec encoder"); + encoder.requestKeyFrame(); + } + } +} diff --git a/app/src/main/proto/control_message.proto b/app/src/main/proto/control_message.proto new file mode 100644 index 0000000..57f0abc --- /dev/null +++ b/app/src/main/proto/control_message.proto @@ -0,0 +1,57 @@ +syntax = "proto3"; + +package com.ttstd.control; + +option java_package = "com.ttstd.control"; +option java_multiple_files = true; + +// 控制指令类型,对应原 JSON 字段 action。 +enum Action { + ACTION_UNKNOWN = 0; + TOUCH = 1; + SWIPE = 2; + KEY = 3; + LONG_PRESS = 4; + MOTION_EVENT = 5; + SET_RESOLUTION = 6; // 控制端请求被控端切换屏幕采集分辨率 + REPORT_RESOLUTION = 7; // 被控端上报当前实际采集分辨率(含连接建立后的初始值) + SET_STREAM_MODE = 8; // 控制端请求被控端切换屏幕串流模式 + REPORT_STREAM_MODE = 9; // 被控端上报当前生效的串流模式 +} + +// 通过 RTCDataChannel 传输的控制指令(protobuf 二进制)。 +// 坐标 x/y/x1/y1/x2/y2 均为相对屏幕的百分比,取值范围 0.0 ~ 1.0。 +message ControlMessage { + // 指令类型 + Action action = 1; + + // 单点坐标(TOUCH / LONG_PRESS / MOTION_EVENT) + double x = 2; + double y = 3; + + // 滑动起止坐标(SWIPE) + double x1 = 4; + double y1 = 5; + double x2 = 6; + double y2 = 7; + int64 duration = 8; + + // 按键(KEY):key_action 0=按下 1=抬起(对应 Android KeyEvent ACTION_DOWN/UP) + int32 key_code = 9; + int32 key_action = 10; + + // 原始动作(MOTION_EVENT):对应 Android MotionEvent ACTION_*(0=DOWN 1=UP 2=MOVE) + int32 motion_action = 11; + + // 分辨率切换(SET_RESOLUTION): + // width 为目标长边/宽度;<=0 表示使用被控端原始(native)分辨率。 + // height 为目标高度;<=0 时按被控端屏幕宽高比基于 width 计算(保留原始比例)。 + // fps 为目标帧率;<=0 表示沿用当前帧率。 + int32 width = 12; + int32 height = 13; + int32 fps = 14; + int32 stream_mode = 15; // 串流模式:0=WebRTC 内置媒体流,1=自编码(MediaCodec 硬编 + DataChannel 透传) + + // 被控端支持的帧率档位列表(REPORT_RESOLUTION 上报,按屏幕刷新率筛选,升序)。 + repeated int32 supported_fps = 16; +} diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml index 7d79e4c..f692464 100644 --- a/app/src/main/res/layout/activity_settings.xml +++ b/app/src/main/res/layout/activity_settings.xml @@ -212,6 +212,7 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_settings_call.xml b/app/src/main/res/layout/activity_settings_call.xml index 12ff237..e9663d2 100644 --- a/app/src/main/res/layout/activity_settings_call.xml +++ b/app/src/main/res/layout/activity_settings_call.xml @@ -86,48 +86,53 @@ android:id="@+id/si_accessibility_service" android:layout_width="match_parent" android:layout_height="wrap_content" - app:disableText="未开启,无障碍服务不可用" - app:enableText="已开启" + app:hintText="使用拨号助手一键拨号" + app:linkageName="accessibility_service" app:optionsText="无障碍服务" /> - - + + diff --git a/app/src/main/res/layout/activity_settings_utils.xml b/app/src/main/res/layout/activity_settings_utils.xml index df9a993..4171fac 100644 --- a/app/src/main/res/layout/activity_settings_utils.xml +++ b/app/src/main/res/layout/activity_settings_utils.xml @@ -86,40 +86,40 @@ android:id="@+id/si_float_window" android:layout_width="match_parent" android:layout_height="wrap_content" - app:disableText="@string/disable_text_float" - app:enableText="@string/enable_text_float" + app:configKey="@string/float_window_enable_key" + app:hintText="@string/hint_text_float" + app:linkageName="float_window" app:optionsText="@string/options_text_float" /> - - - + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 71555e5..b584999 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -10,48 +10,4 @@ ❤拨号助手无障碍服务👈🏻 使用拨号助手一键拨号 - - 快捷通话 - 已开启,快捷联系人通话类型 - 未开启,快捷联系人通话类型 - - 来电语音播报 - 已开启,电话呼入时语音播报联系人 - 未开启,电话呼入时语音播报联系人 - - 微信自动接听 - 已开启,自动接听视频和语音 - 未开启,自动接听视频和语音 - - 自动打开免提 - 已开启,自动开启免提 - 未开启,自动开启免提 - - 微信自动拨打视频 - 已开启,无障碍模式实现微信一键通话 - 未开启,无障碍模式实现微信一键通话 - - - - 全局悬浮按钮 - 已开启,点小圆点可以直接返回桌面 - 未开启,点小圆点可以直接返回桌面 - - 悬浮按钮清理内存 - 已开启,在桌面时点击悬浮窗清理内存 - 未开启,在桌面时点击悬浮窗清理内存 - - 设置为默认桌面 - 已设置为默认桌面 - 未设置为默认桌面 - - 整点报时 - 已开启,整点时将自动语音报时 - 未开启,整点时将自动语音报时 - - 系统辅助助理 - 已开启,可识别屏幕内容和提供语音建议 - 未开启,点击开启设置 - - diff --git a/app/src/main/res/values/strings_options.xml b/app/src/main/res/values/strings_options.xml new file mode 100644 index 0000000..7778ced --- /dev/null +++ b/app/src/main/res/values/strings_options.xml @@ -0,0 +1,60 @@ + + + + 快捷通话 + 快捷联系人通话类型 + fast_call_phone_key + + 来电语音播报 + 电话呼入时语音播报联系人 + voice_broadcast_key + + 微信自动接听 + 自动接听视频和语音 + wechat_auto_accept_call_key + + 自动打开免提 + 自动开启免提 + wechat_auto_hands_free_key + + 微信自动拨打视频 + 无障碍模式实现微信一键通话 + auto_call_key + + + + 全局悬浮按钮 + 点小圆点可以直接返回桌面 + float_window_enable_key + + 悬浮按钮清理内存 + 在桌面时点击悬浮窗清理内存 + float_window_kill_app_key + + 设置为默认桌面 + 将拨号助手设为系统默认桌面 + + 整点报时 + 整点时将自动语音报时 + hourly_chime_enable_key + + 系统辅助助理 + 可识别屏幕内容和提供语音建议 + + + + 远程协助 + 家人远程协助服务 + remote_assistance_key + + 自动接受 + 自动接受远程协助 + accept_remote_assistance_key + + 远程协助请求 + 家人请求远程协助您的设备,是否允许? + 允许 + 拒绝 + + + \ No newline at end of file diff --git a/build.gradle b/build.gradle index 354dd3a..417aa10 100644 --- a/build.gradle +++ b/build.gradle @@ -21,6 +21,9 @@ buildscript { classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + // WebRTC 远程控制:protobuf 编译插件(control_message.proto 生成 Java 类) + classpath 'com.google.protobuf:protobuf-gradle-plugin:0.9.4' + // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files }