Full but still broken rework of everything
This commit is contained in:
+33
-19
@@ -55,14 +55,13 @@ android {
|
||||
versionCode versionCodeOffset + gitCommitCount
|
||||
versionName "${majorVersion}.${minorVersion}.${patchVersion}"
|
||||
|
||||
/*
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
cppFlags += ""
|
||||
arguments "-DANDROID_PLATFORM=android-29"
|
||||
}
|
||||
}
|
||||
*/
|
||||
// externalNativeBuild disabled - native code not ready yet
|
||||
// externalNativeBuild {
|
||||
// cmake {
|
||||
// cppFlags += ""
|
||||
// arguments "-DANDROID_PLATFORM=android-29"
|
||||
// }
|
||||
// }
|
||||
|
||||
ndk {
|
||||
abiFilters.add("arm64-v8a")
|
||||
@@ -72,10 +71,29 @@ android {
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
debug {
|
||||
storeFile file("${System.getProperty('user.home')}/.config/.android/debug.keystore")
|
||||
keyAlias 'androiddebugkey'
|
||||
keyPassword 'android'
|
||||
storePassword 'android'
|
||||
}
|
||||
release {
|
||||
storeFile file("${System.getProperty('user.home')}/.config/.android/debug.keystore")
|
||||
keyAlias 'androiddebugkey'
|
||||
keyPassword 'android'
|
||||
storePassword 'android'
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
signingConfig signingConfigs.debug
|
||||
}
|
||||
release {
|
||||
minifyEnabled true
|
||||
shrinkResources true
|
||||
signingConfig signingConfigs.release
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
@@ -102,14 +120,13 @@ android {
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
path file("src/main/cpp/CMakeLists.txt")
|
||||
version = "3.22.1"
|
||||
}
|
||||
}
|
||||
*/
|
||||
// externalNativeBuild disabled - native code not ready yet
|
||||
// externalNativeBuild {
|
||||
// cmake {
|
||||
// path file("src/main/cpp/CMakeLists.txt")
|
||||
// version = "3.22.1"
|
||||
// }
|
||||
// }
|
||||
|
||||
buildFeatures {
|
||||
compose true
|
||||
@@ -177,9 +194,6 @@ tasks.register("buildNative", Copy) {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
legacyCompileOnly "de.robv.android.xposed:api:82"
|
||||
modernCompileOnly 'io.github.libxposed:api:101.0.0'
|
||||
modernImplementation 'io.github.libxposed:service:101.0.0'
|
||||
implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0"
|
||||
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.10.0"
|
||||
implementation "com.google.accompanist:accompanist-permissions:0.37.3"
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
<application>
|
||||
<meta-data
|
||||
android:name="xposedmodule"
|
||||
android:value="true" />
|
||||
<meta-data
|
||||
android:name="xposeddescription"
|
||||
android:value="A Virtual Camera, support Android 10+ " />
|
||||
<meta-data
|
||||
android:name="xposedminversion"
|
||||
android:value="93" />
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -1 +0,0 @@
|
||||
camera3
|
||||
@@ -1 +0,0 @@
|
||||
com.nothing.camera2magic.MagicHook
|
||||
@@ -1,56 +0,0 @@
|
||||
package com.nothing.camera2magic
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.widget.Toast
|
||||
import com.nothing.camera2magic.hook.SourceManager
|
||||
import de.robv.android.xposed.IXposedHookLoadPackage
|
||||
import de.robv.android.xposed.XC_MethodHook
|
||||
import de.robv.android.xposed.XposedHelpers
|
||||
import de.robv.android.xposed.callbacks.XC_LoadPackage.LoadPackageParam
|
||||
|
||||
class MagicHook : IXposedHookLoadPackage {
|
||||
init {
|
||||
System.loadLibrary("camera3")
|
||||
}
|
||||
companion object {
|
||||
private const val TAG = "[MagicHook]"
|
||||
private const val MODULE_PACKAGE_NAME = "com.nothing.camera2magic"
|
||||
}
|
||||
override fun handleLoadPackage(lpparam: LoadPackageParam) {
|
||||
if (lpparam.packageName == MODULE_PACKAGE_NAME) return
|
||||
GlobalState.packageName = lpparam.packageName
|
||||
|
||||
XposedHelpers.findAndHookMethod(Application::class.java,
|
||||
"onCreate", object : XC_MethodHook() {
|
||||
override fun afterHookedMethod(param: MethodHookParam) {
|
||||
GlobalState.appContext = param.thisObject as Context
|
||||
//TODO:
|
||||
}
|
||||
})
|
||||
|
||||
XposedHelpers.findAndHookMethod(Activity::class.java,
|
||||
"onStart", object : XC_MethodHook() {
|
||||
override fun afterHookedMethod(param: MethodHookParam) {
|
||||
val activity = param.thisObject as Activity
|
||||
GlobalState.activityCount ++
|
||||
if (GlobalState.activityCount == 1) {
|
||||
SourceManager.refreshAndDispatch()
|
||||
activity.runOnUiThread {
|
||||
val text = "[✨] " + SourceManager.toastMessage
|
||||
Toast.makeText(activity, text, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
XposedHelpers.findAndHookMethod(Activity::class.java,
|
||||
"onStop", object : XC_MethodHook() {
|
||||
override fun afterHookedMethod(param: MethodHookParam) {
|
||||
GlobalState.activityCount--
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
@@ -9,7 +9,21 @@
|
||||
android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="32" />
|
||||
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
<uses-permission android:name="android.permission.CREATE_VIRTUAL_DEVICE" />
|
||||
<uses-permission android:name="android.permission.REQUEST_COMPANION_PROFILE_APP_STREAMING" />
|
||||
<uses-permission android:name="android.permission.REQUEST_COMPANION_SELF_MANAGED" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<uses-feature
|
||||
android:name="android.software.companion_device_setup"
|
||||
android:required="false" />
|
||||
|
||||
<application
|
||||
android:name=".CamSwapperApp"
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
@@ -21,15 +35,9 @@
|
||||
android:description="@string/app_description"
|
||||
android:extractNativeLibs="false">
|
||||
|
||||
<meta-data
|
||||
android:name="xposedmodule"
|
||||
android:value="true" />
|
||||
<meta-data
|
||||
android:name="xposeddescription"
|
||||
android:value="CamSwapper Virtual Camera Module" />
|
||||
<meta-data
|
||||
android:name="xposedminversion"
|
||||
android:value="100" />
|
||||
<property
|
||||
android:name="android.app.role"
|
||||
android:value="android.role.app_streaming" />
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
@@ -41,5 +49,14 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".VirtualCameraService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="Virtual camera creation and management via VirtualDeviceManager API" />
|
||||
</service>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.nothing.camera2magic
|
||||
|
||||
import android.app.Application
|
||||
|
||||
class CamSwapperApp : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
GlobalState.appContext = applicationContext
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
package com.nothing.camera2magic
|
||||
|
||||
import android.Manifest
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import android.util.Log
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
@@ -33,6 +37,21 @@ import com.nothing.camera2magic.viewmodel.ViewModelFactory
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
private var virtualCameraService: VirtualCameraService? = null
|
||||
private val serviceConnection = object : ServiceConnection {
|
||||
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
||||
val binder = service as VirtualCameraService.LocalBinder
|
||||
virtualCameraService = binder.getService()
|
||||
virtualCameraService?.startVirtualCamera()
|
||||
Log.i("MainActivity", "VirtualCameraService connected")
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName?) {
|
||||
virtualCameraService = null
|
||||
Log.i("MainActivity", "VirtualCameraService disconnected")
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
@@ -44,34 +63,48 @@ class MainActivity : ComponentActivity() {
|
||||
val factory = remember { ViewModelFactory(application, repository) }
|
||||
VirtualCameraXTheme(dynamicColor = true) {
|
||||
CompositionLocalProvider(LocalViewModelFactory provides factory) {
|
||||
val permissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
listOf(
|
||||
Manifest.permission.READ_MEDIA_IMAGES,
|
||||
Manifest.permission.READ_MEDIA_VIDEO
|
||||
)
|
||||
} else {
|
||||
listOf(Manifest.permission.READ_EXTERNAL_STORAGE)
|
||||
}
|
||||
|
||||
// 2. Create and remember permission state
|
||||
val permissionState = rememberMultiplePermissionsState(permissions)
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
if(permissionState.allPermissionsGranted) {
|
||||
MainScreen()
|
||||
} else {
|
||||
PermissionRationaleScreen(
|
||||
onGrantPermissionClick = { permissionState.launchMultiplePermissionRequest() }
|
||||
val permissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
listOf(
|
||||
Manifest.permission.READ_MEDIA_IMAGES,
|
||||
Manifest.permission.READ_MEDIA_VIDEO
|
||||
)
|
||||
} else {
|
||||
listOf(Manifest.permission.READ_EXTERNAL_STORAGE)
|
||||
}
|
||||
|
||||
// 2. Create and remember permission state
|
||||
val permissionState = rememberMultiplePermissionsState(permissions)
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
if(permissionState.allPermissionsGranted) {
|
||||
MainScreen()
|
||||
} else {
|
||||
PermissionRationaleScreen(
|
||||
onGrantPermissionClick = { permissionState.launchMultiplePermissionRequest() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
val intent = Intent(this, VirtualCameraService::class.java)
|
||||
startForegroundService(intent)
|
||||
bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE)
|
||||
Log.i("MainActivity", "VirtualCameraService started and bound")
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
unbindService(serviceConnection)
|
||||
Log.i("MainActivity", "VirtualCameraService unbound")
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
package com.nothing.camera2magic
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.Service
|
||||
import android.companion.AssociationInfo
|
||||
import android.companion.AssociationRequest
|
||||
import android.companion.CompanionDeviceManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentSender
|
||||
import android.os.Binder
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import android.view.Surface
|
||||
import androidx.annotation.RequiresApi
|
||||
import com.nothing.camera2magic.hook.VideoPusher
|
||||
import com.nothing.camera2magic.R
|
||||
import java.lang.reflect.InvocationHandler
|
||||
import java.lang.reflect.Proxy
|
||||
import java.util.concurrent.Executor
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/**
|
||||
* [VirtualCameraService] wraps Android VDM/VirtualCamera @SystemApi behind reflection.
|
||||
*
|
||||
* On this device (Pixel 9a, Android 16) the VDM classes live in
|
||||
* `android.companion.virtual.*` (not `android.companion.*`).
|
||||
* We obtain the service via the public [getSystemService] API, then
|
||||
* derive the correct package prefix from the returned object's class at
|
||||
* runtime so the class names are always correct regardless of device build.
|
||||
*
|
||||
* CompanionDeviceManager, AssociationRequest, AssociationInfo are public API.
|
||||
*/
|
||||
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
|
||||
class VirtualCameraService : Service() {
|
||||
|
||||
companion object {
|
||||
const val TAG = "VirtualCameraService"
|
||||
const val CHANNEL_ID = "virtual_camera_channel"
|
||||
const val NOTIFICATION_ID = 1
|
||||
}
|
||||
|
||||
private var virtualDevice: Any? = null
|
||||
private var videoPusherActive = false
|
||||
private var callbackExecutor: Executor? = null
|
||||
private var vdmPkg: String = "android.companion.virtual" // default, overridden at runtime
|
||||
|
||||
private val binder = LocalBinder()
|
||||
|
||||
inner class LocalBinder : Binder() {
|
||||
fun getService(): VirtualCameraService = this@VirtualCameraService
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
createNotificationChannel()
|
||||
Log.i(TAG, "Service created")
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder = binder
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
startForeground(NOTIFICATION_ID, buildNotification("Virtual camera service running"))
|
||||
Log.i(TAG, "onStartCommand")
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
stopVirtualCamera()
|
||||
super.onDestroy()
|
||||
Log.i(TAG, "Service destroyed")
|
||||
}
|
||||
|
||||
private fun vdmClass(shortName: String): Class<*> =
|
||||
Class.forName("$vdmPkg.$shortName")
|
||||
|
||||
fun startVirtualCamera() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
Log.e(TAG, "VirtualDeviceManager requires API 34+")
|
||||
updateNotification("Error: API 34+ required")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val vdm = getSystemService("virtualdevice") ?: run {
|
||||
Log.e(TAG, "VirtualDeviceManager not available")
|
||||
updateNotification("Error: VirtualDeviceManager not available")
|
||||
return
|
||||
}
|
||||
Log.i(TAG, "VirtualDeviceManager obtained: $vdm")
|
||||
|
||||
// Extract the actual VDM package (e.g. android.companion.virtual)
|
||||
// from the runtime object so class names are correct on any build.
|
||||
val vdmClass = vdm.javaClass
|
||||
val fullName = vdmClass.name
|
||||
vdmPkg = fullName.substringBeforeLast(".")
|
||||
|
||||
val params = buildVirtualDeviceParams()
|
||||
Log.i(TAG, "VirtualDeviceParams created")
|
||||
|
||||
startCdmAssociation(vdm, vdmClass, params)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to start virtual camera", e)
|
||||
updateNotification("Error: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun stopVirtualCamera() {
|
||||
try {
|
||||
if (videoPusherActive) {
|
||||
VideoPusher.stop()
|
||||
videoPusherActive = false
|
||||
}
|
||||
virtualDevice?.let { device ->
|
||||
device.javaClass.getMethod("close").invoke(device)
|
||||
virtualDevice = null
|
||||
Log.i(TAG, "Virtual camera stopped")
|
||||
updateNotification("Virtual camera stopped")
|
||||
}
|
||||
callbackExecutor?.let { (it as? java.util.concurrent.ExecutorService)?.shutdown() }
|
||||
callbackExecutor = null
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error stopping virtual camera", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildVirtualDeviceParams(): Any {
|
||||
val paramsClass = vdmClass("VirtualDeviceParams")
|
||||
val builderClass = vdmClass("VirtualDeviceParams\$Builder")
|
||||
val builder = builderClass.getDeclaredConstructor().newInstance()
|
||||
|
||||
builderClass.getMethod("setName", String::class.java)
|
||||
.invoke(builder, "CamSwapper Virtual Camera")
|
||||
|
||||
val policyTypeCamera = paramsClass.getDeclaredField("POLICY_TYPE_CAMERA").get(null) as Int
|
||||
val devicePolicyCustom = paramsClass.getDeclaredField("DEVICE_POLICY_CUSTOM").get(null) as Int
|
||||
builderClass.getMethod("setDevicePolicy", Int::class.javaPrimitiveType, Int::class.javaPrimitiveType)
|
||||
.invoke(builder, policyTypeCamera, devicePolicyCustom)
|
||||
|
||||
return builderClass.getMethod("build").invoke(builder)
|
||||
}
|
||||
|
||||
private fun startCdmAssociation(vdm: Any, vdmClass: Class<*>, params: Any) {
|
||||
val cdm = getSystemService(CompanionDeviceManager::class.java)
|
||||
if (cdm == null) {
|
||||
Log.w(TAG, "CompanionDeviceManager not available")
|
||||
updateNotification("Error: CDM not available")
|
||||
return
|
||||
}
|
||||
|
||||
// Use setDeviceProfile() — public API since API 31 — instead of
|
||||
// DeviceFilter.Builder (which doesn't exist on all SDK builds).
|
||||
// DEVICE_PROFILE_APP_STREAMING grants us a companion association for
|
||||
// virtual display / app streaming use-cases (i.e. virtual camera).
|
||||
val request = try {
|
||||
AssociationRequest.Builder()
|
||||
.setSingleDevice(true)
|
||||
.setDeviceProfile(AssociationRequest.DEVICE_PROFILE_APP_STREAMING)
|
||||
.setSelfManaged(true)
|
||||
.setDisplayName("CamSwapper Virtual Camera")
|
||||
.build()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to build AssociationRequest", e)
|
||||
updateNotification("Error: Association request failed")
|
||||
return
|
||||
}
|
||||
val handler = Handler(Looper.getMainLooper())
|
||||
|
||||
val callback = object : CompanionDeviceManager.Callback() {
|
||||
override fun onAssociationPending(intentSender: IntentSender) {
|
||||
Log.i(TAG, "CDM association pending — requesting user consent")
|
||||
try {
|
||||
startIntentSender(intentSender, null, 0, 0, 0)
|
||||
} catch (e: Exception) {
|
||||
// CompanionAssociationActivity may not exist on some builds.
|
||||
Log.e(TAG, "Failed to show consent dialog", e)
|
||||
// Try creating virtual device without CDM association
|
||||
Log.i(TAG, "Trying direct VDM association bypass...")
|
||||
tryDirectVdm(vdm, vdmClass, params)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAssociationCreated(associationInfo: AssociationInfo) {
|
||||
Log.i(TAG, "CDM association created: id=${associationInfo.id}")
|
||||
val method = vdmClass.getMethod(
|
||||
"createVirtualDevice",
|
||||
Int::class.javaPrimitiveType,
|
||||
vdmClass("VirtualDeviceParams")
|
||||
)
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val device = method.invoke(vdm, associationInfo.id, params) as? Any
|
||||
if (device == null) {
|
||||
Log.e(TAG, "createVirtualDevice returned null")
|
||||
// Fallback: try without CDM
|
||||
tryDirectVdm(vdm, vdmClass, params)
|
||||
return
|
||||
}
|
||||
Log.i(TAG, "VirtualDevice created: $device")
|
||||
virtualDevice = device
|
||||
createVirtualCamera(device)
|
||||
}
|
||||
|
||||
override fun onFailure(error: CharSequence?) {
|
||||
Log.e(TAG, "CDM association failed: $error")
|
||||
// Fallback: try without CDM
|
||||
tryDirectVdm(vdm, vdmClass, params)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
cdm.associate(request, callback, handler)
|
||||
Log.i(TAG, "CDM association requested — waiting for user consent")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "cdm.associate threw, trying direct VDM", e)
|
||||
tryDirectVdm(vdm, vdmClass, params)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to create a VirtualDevice without CDM association by probing
|
||||
* the [VirtualDeviceManager] methods at runtime. On some builds the
|
||||
* device may accept a request without a companion association (e.g.
|
||||
* via reflection on internal methods or a privileged-only overload).
|
||||
*/
|
||||
private fun tryDirectVdm(vdm: Any, vdmClass: Class<*>, params: Any) {
|
||||
try {
|
||||
// Log available methods for debugging
|
||||
Log.i(TAG, "=== VirtualDeviceManager methods ===")
|
||||
for (m in vdmClass.methods.sortedBy { it.name }) {
|
||||
Log.i(TAG, " ${m.name}(${m.parameterTypes.joinToString { it.simpleName }})")
|
||||
}
|
||||
|
||||
// Try createVirtualDevice(VirtualDeviceParams) — no association needed
|
||||
val directMethod = vdmClass.methods.firstOrNull { m ->
|
||||
m.name == "createVirtualDevice" &&
|
||||
m.parameterTypes.size == 1
|
||||
}
|
||||
if (directMethod != null) {
|
||||
Log.i(TAG, "Found direct createVirtualDevice, trying...")
|
||||
val device = directMethod.invoke(vdm, params) as? Any
|
||||
if (device != null) {
|
||||
Log.i(TAG, "VirtualDevice created via direct API: $device")
|
||||
virtualDevice = device
|
||||
createVirtualCamera(device)
|
||||
return
|
||||
}
|
||||
Log.w(TAG, "Direct createVirtualDevice returned null")
|
||||
}
|
||||
|
||||
// Try createVirtualDevice(0, params) with dummy association ID
|
||||
val intMethod = vdmClass.getMethod(
|
||||
"createVirtualDevice",
|
||||
Int::class.javaPrimitiveType,
|
||||
vdmClass("VirtualDeviceParams")
|
||||
)
|
||||
Log.i(TAG, "Trying createVirtualDevice(0, params)...")
|
||||
val device = intMethod.invoke(vdm, 0, params) as? Any
|
||||
if (device != null) {
|
||||
Log.i(TAG, "VirtualDevice created with id=0: $device")
|
||||
virtualDevice = device
|
||||
createVirtualCamera(device)
|
||||
return
|
||||
}
|
||||
Log.w(TAG, "createVirtualDevice(0, params) returned null")
|
||||
updateNotification("Error: No VDM method worked")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "All VDM methods failed", e)
|
||||
updateNotification("Error: VDM unavailable")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to load a class from a sub-package under [vdmPkg].
|
||||
* On Android 16 (API 36) virtual camera classes moved from
|
||||
* `android.companion.virtual.*` to `android.companion.virtual.camera.*`.
|
||||
*/
|
||||
private fun vdmSubClass(subPkg: String, shortName: String): Class<*> =
|
||||
Class.forName("$vdmPkg.$subPkg.$shortName")
|
||||
|
||||
private fun createVirtualCamera(device: Any) {
|
||||
try {
|
||||
val cameraPkg = "camera"
|
||||
|
||||
val callbackClass = vdmSubClass(cameraPkg, "VirtualCameraCallback")
|
||||
val executor = Executors.newSingleThreadExecutor()
|
||||
callbackExecutor = executor
|
||||
|
||||
val callback = Proxy.newProxyInstance(
|
||||
callbackClass.classLoader,
|
||||
arrayOf(callbackClass),
|
||||
InvocationHandler { _, method, args ->
|
||||
when (method.name) {
|
||||
"onStreamConfigured" -> {
|
||||
// Android 16 passes 5 args: streamId, Surface, width, height, format
|
||||
val streamId = args[0] as Int
|
||||
val surface = args[1] as Surface
|
||||
val width = args[2] as Int
|
||||
val height = args[3] as Int
|
||||
Log.i(
|
||||
TAG,
|
||||
"onStreamConfigured: streamId=$streamId, ${width}x${height}"
|
||||
)
|
||||
onStreamConfigured(surface, width, height)
|
||||
}
|
||||
"onStreamClosed" -> {
|
||||
val streamId = args[0] as Int
|
||||
Log.i(TAG, "onStreamClosed: streamId=$streamId")
|
||||
onStreamClosed()
|
||||
}
|
||||
"onProcessCaptureRequest" -> {
|
||||
// Android 16 callback — no-op for now
|
||||
Log.d(TAG, "onProcessCaptureRequest: streamId=${args[0]}")
|
||||
}
|
||||
else -> {
|
||||
Log.w(TAG, "Unknown VirtualCameraCallback: ${method.name}")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
val configBuilderClass = vdmSubClass(cameraPkg, "VirtualCameraConfig\$Builder")
|
||||
val configClass = vdmSubClass(cameraPkg, "VirtualCameraConfig")
|
||||
|
||||
// Probe available constructors: try single-arg (String) first, fall back to (String, Set)
|
||||
val configBuilder = try {
|
||||
configBuilderClass.getDeclaredConstructor(String::class.java)
|
||||
.newInstance("CamSwapper")
|
||||
} catch (_: NoSuchMethodException) {
|
||||
configBuilderClass.getDeclaredConstructor(
|
||||
String::class.java,
|
||||
Set::class.java
|
||||
).newInstance("CamSwapper", emptySet<Any>())
|
||||
}
|
||||
|
||||
configBuilderClass.getMethod(
|
||||
"addStreamConfig",
|
||||
Int::class.javaPrimitiveType,
|
||||
Int::class.javaPrimitiveType,
|
||||
Int::class.javaPrimitiveType,
|
||||
Int::class.javaPrimitiveType
|
||||
).invoke(configBuilder, 1920, 1080, android.graphics.ImageFormat.YUV_420_888, 30)
|
||||
|
||||
// CameraCharacteristics.LENS_FACING_FRONT = 0 (stable since API 21)
|
||||
// Use literal value to avoid reflection issues on Android 16
|
||||
configBuilderClass.getMethod("setLensFacing", Int::class.javaPrimitiveType)
|
||||
.invoke(configBuilder, 0)
|
||||
|
||||
configBuilderClass.getMethod(
|
||||
"setVirtualCameraCallback",
|
||||
Executor::class.java,
|
||||
callbackClass
|
||||
).invoke(configBuilder, executor, callback)
|
||||
|
||||
val config = configBuilderClass.getMethod("build").invoke(configBuilder)
|
||||
Log.i(TAG, "VirtualCameraConfig built")
|
||||
|
||||
device.javaClass.getMethod("createVirtualCamera", configClass)
|
||||
.invoke(device, config)
|
||||
|
||||
Log.i(TAG, "Virtual camera created and running")
|
||||
updateNotification("Virtual camera running")
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to create virtual camera", e)
|
||||
updateNotification("Error: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun onStreamConfigured(surface: Surface, width: Int, height: Int) {
|
||||
updateNotification("Stream active: ${width}x${height}")
|
||||
|
||||
val prefs = getSharedPreferences("camera_magic_config", MODE_PRIVATE)
|
||||
val videoId = prefs.getLong("local_video_id", -1L)
|
||||
|
||||
if (videoId > 0) {
|
||||
VideoPusher.start(listOf(surface), width, height, videoId)
|
||||
videoPusherActive = true
|
||||
} else {
|
||||
Log.w(TAG, "No video selected (videoId=$videoId)")
|
||||
}
|
||||
}
|
||||
|
||||
private fun onStreamClosed() {
|
||||
if (videoPusherActive) {
|
||||
VideoPusher.stop()
|
||||
videoPusherActive = false
|
||||
}
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"Virtual Camera Service",
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
)
|
||||
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
nm.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
private fun buildNotification(text: String): Notification {
|
||||
return Notification.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle("CamSwapper")
|
||||
.setContentText(text)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun updateNotification(text: String) {
|
||||
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
nm.notify(NOTIFICATION_ID, buildNotification(text))
|
||||
}
|
||||
}
|
||||
-52
@@ -2,8 +2,6 @@ package com.nothing.camera2magic.viewmodel
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import android.util.Log
|
||||
import io.github.libxposed.service.XposedService
|
||||
import io.github.libxposed.service.XposedServiceHelper
|
||||
import androidx.core.content.edit
|
||||
|
||||
private const val TAG = "[VCX][ConfigRepo]"
|
||||
@@ -33,21 +31,6 @@ enum class MediaType(val value: Int, val mimeType: String) {
|
||||
|
||||
class ConfigRepository(private val prefs: SharedPreferences) {
|
||||
|
||||
private var xposedService: XposedService? = null
|
||||
|
||||
init {
|
||||
XposedServiceHelper.registerListener(object : XposedServiceHelper.OnServiceListener {
|
||||
override fun onServiceBind(service: XposedService) {
|
||||
Log.i(TAG, "XposedService bound, syncing all to remote")
|
||||
xposedService = service
|
||||
syncAllToRemote()
|
||||
}
|
||||
override fun onServiceDied(service: XposedService) {
|
||||
xposedService = null
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun <T> save(key: String, value: T) {
|
||||
prefs.edit {
|
||||
when (value) {
|
||||
@@ -59,41 +42,6 @@ class ConfigRepository(private val prefs: SharedPreferences) {
|
||||
else -> throw IllegalArgumentException("Unsupported type")
|
||||
}
|
||||
}
|
||||
|
||||
xposedService?.let { service ->
|
||||
try {
|
||||
val remotePrefs = service.getRemotePreferences(GROUP_NAME)
|
||||
remotePrefs.edit {
|
||||
when (value) {
|
||||
is Boolean -> putBoolean(key, value)
|
||||
is Int -> putInt(key, value)
|
||||
is Long -> putLong(key, value)
|
||||
is Float -> putFloat(key, value)
|
||||
is String -> putString(key, value)
|
||||
else -> throw IllegalArgumentException("Unsupported type")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to set_internal_state remote preferences", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
private fun syncAllToRemote() {
|
||||
xposedService?.let { service ->
|
||||
val remotePrefs = service.getRemotePreferences(GROUP_NAME)
|
||||
remotePrefs.edit {
|
||||
prefs.all.forEach { (key, value) ->
|
||||
when (value) {
|
||||
is Boolean -> putBoolean(key, value)
|
||||
is Int -> putInt(key, value)
|
||||
is Long -> putLong(key, value)
|
||||
is Float -> putFloat(key, value)
|
||||
is String -> putString(key, value)
|
||||
else -> throw IllegalArgumentException("Unsupported type")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var moduleEnabled: Boolean
|
||||
Binary file not shown.
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M12,8c-2.21,0 -4,1.79 -4,4s1.79,4 4,4 4,-1.79 4,-4 -1.79,-4 -4,-4zM20,5h-3.17l-1.24,-1.35c-0.37,-0.41 -0.88,-0.65 -1.42,-0.65H9.83c-0.54,0 -1.05,0.24 -1.42,0.65L7.17,5H4c-1.1,0 -2,0.9 -2,2v10c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2V7c0,-1.1 -0.9,-2 -2,-2zM12,18c-3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6 6,2.69 6,6 -2.69,6 -6,6z" />
|
||||
</vector>
|
||||
@@ -1,78 +0,0 @@
|
||||
package com.nothing.camera2magic
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.widget.Toast
|
||||
import com.nothing.camera2magic.hook.Camera1Hooker
|
||||
import com.nothing.camera2magic.hook.Camera2Hooker
|
||||
import com.nothing.camera2magic.hook.SourceManager
|
||||
import com.nothing.camera2magic.hook.CameraDeviceImplHooker
|
||||
import com.nothing.camera2magic.hook.WebRTCHooker
|
||||
import com.nothing.camera2magic.utils.Dog
|
||||
import io.github.libxposed.api.XposedModule
|
||||
import io.github.libxposed.api.XposedModuleInterface.PackageReadyParam
|
||||
|
||||
class MagicHook : XposedModule() {
|
||||
|
||||
init {
|
||||
System.loadLibrary("camera3")
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "[MagicHook]"
|
||||
}
|
||||
|
||||
override fun onPackageReady(param: PackageReadyParam) {
|
||||
// Hook for all packages - user will scope to camera apps in LSPosed
|
||||
Dog.i(TAG, "onPackageReady called for package: ${param.packageName}", true)
|
||||
// Do not rely on per-app packageName state for system-wide operation.
|
||||
val remotePrefs = getRemotePreferences("camera_magic_config")
|
||||
SourceManager.skipNativeDispatch = true
|
||||
SourceManager.init(remotePrefs)
|
||||
hookAttach()
|
||||
hookActivity()
|
||||
// Store package name BEFORE hookers (they may access GlobalState.packageName)
|
||||
GlobalState.packageName = param.packageName
|
||||
// App-level hookers (per-app LSPosed scope) - PROVEN WORKING
|
||||
Camera1Hooker.initHooks(this, param)
|
||||
Camera2Hooker.initHooks(this, param)
|
||||
WebRTCHooker.initHooks(this, param)
|
||||
// System-wide CameraDeviceImpl hooker - EXPERIMENTAL, DISABLED BY DEFAULT
|
||||
// Uncomment to test system-wide hooking (scope module to "Android System" in LSPosed)
|
||||
// NOTE: Camera2Hooker and CameraDeviceImplHooker both hook CameraDeviceImpl methods.
|
||||
// Having both active simultaneously causes conflicts. Only enable ONE at a time.
|
||||
// CameraDeviceImplHooker.initHooks(this, param)
|
||||
}
|
||||
|
||||
@SuppressLint("DiscouragedPrivateApi")
|
||||
private fun hookAttach() {
|
||||
val attach = Application::class.java.getDeclaredMethod("attach", Context::class.java)
|
||||
hook(attach).intercept { chain ->
|
||||
GlobalState.appContext = chain.args[0] as Context
|
||||
chain.proceed()
|
||||
}
|
||||
}
|
||||
|
||||
private fun hookActivity() {
|
||||
val start = Activity::class.java.getDeclaredMethod("onStart")
|
||||
hook(start).intercept { chain ->
|
||||
val result = chain.proceed()
|
||||
val activity = chain.thisObject as Activity
|
||||
GlobalState.activityCount ++
|
||||
SourceManager.refreshAndDispatch()
|
||||
activity.runOnUiThread {
|
||||
val text = "[✨] " + SourceManager.toastMessage
|
||||
Toast.makeText(activity, text, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
return@intercept result
|
||||
}
|
||||
|
||||
val stop = Activity::class.java.getDeclaredMethod("onStop")
|
||||
hook(stop).intercept { chain ->
|
||||
chain.proceed()
|
||||
GlobalState.activityCount--
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,275 +0,0 @@
|
||||
@file:Suppress("DEPRECATION")
|
||||
|
||||
package com.nothing.camera2magic.hook
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.hardware.Camera
|
||||
import android.view.Surface
|
||||
import android.view.SurfaceHolder
|
||||
import android.graphics.SurfaceTexture
|
||||
import com.nothing.camera2magic.GlobalState
|
||||
import com.nothing.camera2magic.MagicHook
|
||||
import com.nothing.camera2magic.utils.Dog
|
||||
import io.github.libxposed.api.XposedInterface.Chain
|
||||
import io.github.libxposed.api.XposedModuleInterface.PackageReadyParam
|
||||
import java.lang.ref.WeakReference
|
||||
import java.lang.reflect.Proxy
|
||||
import java.util.Collections
|
||||
import java.util.Timer
|
||||
import java.util.WeakHashMap
|
||||
import kotlin.concurrent.schedule
|
||||
|
||||
object Camera1Hooker {
|
||||
private const val TAG = "[CAM1]"
|
||||
|
||||
private val Camera?.shortId : String
|
||||
get() = if (this == null) "null" else "@0x${Integer.toHexString(System.identityHashCode(this))}"
|
||||
|
||||
private var activeCameraRef: WeakReference<Camera>? = null
|
||||
private var cameraState = WeakHashMap<Camera, CameraState>()
|
||||
private var pushMode = false
|
||||
private var blackHole: Any? = null
|
||||
private fun destroyBlackHole() {
|
||||
when (blackHole) {
|
||||
is SurfaceTexture -> {
|
||||
(blackHole as SurfaceTexture).release()
|
||||
}
|
||||
is Surface -> {
|
||||
(blackHole as Surface).release()
|
||||
}
|
||||
}
|
||||
blackHole = null
|
||||
}
|
||||
private fun getCameraState(camera: Camera): CameraState {
|
||||
return synchronized(cameraState) {
|
||||
cameraState.getOrPut(camera) { CameraState() }
|
||||
}
|
||||
}
|
||||
private fun isPreviewing(camera: Camera): Boolean {
|
||||
return activeCameraRef?.get() === camera
|
||||
}
|
||||
|
||||
private lateinit var magic: MagicHook
|
||||
private val hookedClasses = Collections.synchronizedSet(
|
||||
Collections.newSetFromMap(WeakHashMap<Class<*>, Boolean>()))
|
||||
|
||||
fun initHooks(module: MagicHook, param: PackageReadyParam) {
|
||||
magic = module
|
||||
Camera::class.java.apply {
|
||||
hookOpenMethod()
|
||||
hookSetParameters()
|
||||
hookSetPreviewTexture()
|
||||
hookSetPreviewDisplay()
|
||||
hookSetDisplayOrientation()
|
||||
hookStartPreview()
|
||||
hookStopPreview()
|
||||
hookRelease()
|
||||
hookSetPreviewCallback()
|
||||
hookAddCallbackBuffer()
|
||||
hookTakePicture()
|
||||
}
|
||||
}
|
||||
private val openInterceptor: (Chain) -> Any? = intercept@{ chain ->
|
||||
val camera = chain.proceed() as? Camera ?: return@intercept null
|
||||
activeCameraRef = WeakReference(camera)
|
||||
val cameraId = chain.args.getOrNull(0) as? Int ?: 0
|
||||
val info = Camera.CameraInfo()
|
||||
Camera.getCameraInfo(cameraId, info)
|
||||
val state = getCameraState(camera)
|
||||
|
||||
state.apiLevel = 1
|
||||
state.facingFront = info.facing == Camera.CameraInfo.CAMERA_FACING_FRONT
|
||||
state.sensorOrientation = info.orientation
|
||||
state.packageName = GlobalState.packageName
|
||||
camera
|
||||
}
|
||||
private fun Class<*>.hookOpenMethod() {
|
||||
val open = getDeclaredMethod("open")
|
||||
val openId = getDeclaredMethod("open", Int::class.java)
|
||||
magic.hook(open).intercept(openInterceptor)
|
||||
magic.hook(openId).intercept(openInterceptor)
|
||||
}
|
||||
private fun Class<*>.hookSetParameters() {
|
||||
val setParameters = getDeclaredMethod("setParameters", Camera.Parameters::class.java)
|
||||
magic.hook(setParameters).intercept { chain ->
|
||||
chain.proceed()
|
||||
val camera = chain.thisObject as Camera
|
||||
val params = chain.args[0] as Camera.Parameters
|
||||
val pictureSize = params.pictureSize
|
||||
val previewSize = params.previewSize
|
||||
val state = getCameraState(camera)
|
||||
if (state.pictureWidth != pictureSize.width || state.pictureHeight != pictureSize.height) {
|
||||
state.pictureWidth = pictureSize.width
|
||||
state.pictureHeight = pictureSize.height
|
||||
}
|
||||
if (state.previewWidth != previewSize.width || state.previewHeight != previewSize.height) {
|
||||
state.previewWidth = previewSize.width
|
||||
state.previewHeight = previewSize.height
|
||||
}
|
||||
}
|
||||
}
|
||||
private fun Class<*>.hookSetPreviewTexture() {
|
||||
val setPreviewTexture = getDeclaredMethod("setPreviewTexture",
|
||||
SurfaceTexture::class.java)
|
||||
|
||||
magic.hook(setPreviewTexture).intercept { chain ->
|
||||
if (!SourceManager.isReadyForHook()) return@intercept chain.proceed()
|
||||
val camera = chain.thisObject as Camera
|
||||
val surfaceTexture = chain.args[0] as SurfaceTexture
|
||||
val state = getCameraState(camera)
|
||||
|
||||
val fakeSurfaceTexture = SurfaceTexture(false)
|
||||
.apply { setDefaultBufferSize(1, 1) }
|
||||
val fakeSurface = Surface(fakeSurfaceTexture)
|
||||
state.surfaces.clear()
|
||||
state.surfaces.add(fakeSurface)
|
||||
blackHole = fakeSurfaceTexture.also { chain.proceed(arrayOf(it)) }
|
||||
}
|
||||
}
|
||||
private fun Class<*>.hookSetPreviewDisplay() {
|
||||
val setPreviewDisplay = getDeclaredMethod(
|
||||
"setPreviewDisplay",
|
||||
SurfaceHolder::class.java)
|
||||
|
||||
magic.hook(setPreviewDisplay).intercept { chain ->
|
||||
if (!SourceManager.isReadyForHook()) return@intercept chain.proceed()
|
||||
pushMode = true
|
||||
val camera = chain.thisObject as Camera
|
||||
val holder = chain.args[0] as SurfaceHolder
|
||||
val state = getCameraState(camera)
|
||||
@SuppressLint("Recycle")
|
||||
val surfaceTexture = SurfaceTexture(false)
|
||||
.apply { setDefaultBufferSize(1, 1) }
|
||||
val surface = Surface(surfaceTexture).also { blackHole = it }
|
||||
state.surfaces.clear()
|
||||
state.surfaces.add(surface)
|
||||
val surfaceHolderProxy = Proxy.newProxyInstance(holder.javaClass.classLoader,
|
||||
arrayOf(SurfaceHolder::class.java)) { _, method, args ->
|
||||
if (method.name == "getSurface") return@newProxyInstance surface
|
||||
return@newProxyInstance method.invoke(holder, *(args ?: arrayOfNulls<Any>(0)))
|
||||
} as SurfaceHolder
|
||||
chain.proceed(arrayOf(surfaceHolderProxy))
|
||||
}
|
||||
}
|
||||
private fun Class<*>.hookSetDisplayOrientation() {
|
||||
val setDisplayOrientation = getDeclaredMethod(
|
||||
"setDisplayOrientation",
|
||||
Int::class.javaPrimitiveType)
|
||||
|
||||
magic.hook(setDisplayOrientation).intercept { chain ->
|
||||
val camera = chain.thisObject as Camera
|
||||
val state = getCameraState(camera)
|
||||
val displayOrientation = chain.args[0] as Int
|
||||
if (!SourceManager.isReadyForHook() || state.displayOrientation == displayOrientation) return@intercept chain.proceed()
|
||||
state.displayOrientation = displayOrientation
|
||||
if (isPreviewing(camera)) {
|
||||
NativeBridge.setDisplayOrientation(displayOrientation)
|
||||
}
|
||||
chain.proceed()
|
||||
}
|
||||
}
|
||||
private fun Class<*>.hookStartPreview() {
|
||||
val startPreview = getDeclaredMethod("startPreview")
|
||||
magic.hook(startPreview).intercept { chain ->
|
||||
if (!SourceManager.isReadyForHook()) return@intercept chain.proceed()
|
||||
val camera = chain.thisObject as Camera
|
||||
val state = getCameraState(camera)
|
||||
val activeCamera = activeCameraRef?.get()
|
||||
if (activeCamera != null && camera === activeCamera) {
|
||||
NativeBridge.registerSurfaceIfNew(state, true)
|
||||
NativeBridge.needStartRenderer()
|
||||
}
|
||||
chain.proceed()
|
||||
}
|
||||
}
|
||||
private fun Class<*>.hookStopPreview() {
|
||||
val stopPreview = getDeclaredMethod("stopPreview")
|
||||
magic.hook(stopPreview).intercept { chain ->
|
||||
if (!SourceManager.isReadyForHook()) return@intercept chain.proceed()
|
||||
val camera = chain.thisObject as Camera
|
||||
val activeCamera = activeCameraRef?.get()
|
||||
if (activeCamera != null && camera === activeCamera) {
|
||||
NativeBridge.needStopRenderer()
|
||||
}
|
||||
chain.proceed()
|
||||
}
|
||||
}
|
||||
private fun Class<*>.hookRelease() {
|
||||
val release = getDeclaredMethod("release")
|
||||
magic.hook(release).intercept { chain ->
|
||||
if (!SourceManager.isReadyForHook()) return@intercept chain.proceed()
|
||||
val closingCamera = chain.thisObject as Camera
|
||||
val activeCamera = activeCameraRef?.get()
|
||||
|
||||
if (activeCamera != null && closingCamera === activeCamera) {
|
||||
NativeBridge.needStopRenderer()
|
||||
NativeBridge.releaseLastRegisteredSurface()
|
||||
destroyBlackHole()
|
||||
activeCameraRef = null
|
||||
}
|
||||
chain.proceed()
|
||||
}
|
||||
}
|
||||
|
||||
private val previewCallbackInterceptor: (Chain) -> Any? = intercept@ { chain ->
|
||||
if (!SourceManager.isReadyForHook()) return@intercept chain.proceed()
|
||||
val camera = chain.thisObject as Camera
|
||||
val originCallback = chain.args[0] as? Camera.PreviewCallback ?: return@intercept chain.proceed()
|
||||
val clazz = originCallback.javaClass
|
||||
if (hookedClasses.add(clazz)) {
|
||||
val onPreviewFrame = clazz.getDeclaredMethod(
|
||||
"onPreviewFrame",
|
||||
ByteArray::class.java,
|
||||
Camera::class.java)
|
||||
magic.hook(onPreviewFrame).intercept { frame ->
|
||||
val originBuffer = frame.args[0] as ByteArray
|
||||
NativeBridge.overwritePreviewBuffer(originBuffer)
|
||||
frame.proceed()
|
||||
}
|
||||
}
|
||||
chain.proceed()
|
||||
}
|
||||
|
||||
private fun Class<*>.hookSetPreviewCallback() {
|
||||
val setPreviewCallback = getDeclaredMethod(
|
||||
"setPreviewCallback",
|
||||
Camera.PreviewCallback::class.java)
|
||||
val setPreviewCallbackWithBuffer = getDeclaredMethod(
|
||||
"setPreviewCallbackWithBuffer",
|
||||
Camera.PreviewCallback::class.java)
|
||||
magic.hook(setPreviewCallback).intercept(previewCallbackInterceptor)
|
||||
magic.hook(setPreviewCallbackWithBuffer).intercept(previewCallbackInterceptor)
|
||||
}
|
||||
|
||||
private fun Class<*>.hookAddCallbackBuffer() {
|
||||
val addCallbackBuffer = getDeclaredMethod("addCallbackBuffer",
|
||||
ByteArray::class.java)
|
||||
// TODO:
|
||||
}
|
||||
|
||||
private fun Class<*>.hookTakePicture() {
|
||||
val takePicture = getDeclaredMethod(
|
||||
"takePicture",
|
||||
Camera.ShutterCallback::class.java,
|
||||
Camera.PictureCallback::class.java, // raw
|
||||
Camera.PictureCallback::class.java, // post view
|
||||
Camera.PictureCallback::class.java) // jpeg
|
||||
|
||||
magic.hook(takePicture).intercept { chain ->
|
||||
if (!SourceManager.isReadyForHook()) return@intercept chain.proceed()
|
||||
chain.args[3]?.let { cb ->
|
||||
val clazz = (cb as Camera.PictureCallback).javaClass
|
||||
if (hookedClasses.add(clazz)) {
|
||||
val onPictureTaken = clazz.getDeclaredMethod("onPictureTaken",
|
||||
ByteArray::class.java, Camera::class.java)
|
||||
magic.hook(onPictureTaken).intercept { shot ->
|
||||
val newArgs = shot.args.toTypedArray()
|
||||
newArgs[0] = NativeBridge.overwriteJPEGBytes()
|
||||
shot.proceed(newArgs)
|
||||
}
|
||||
}
|
||||
}
|
||||
chain.proceed()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,342 +0,0 @@
|
||||
package com.nothing.camera2magic.hook
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.hardware.camera2.CameraCaptureSession
|
||||
import android.hardware.camera2.CameraDevice
|
||||
import android.hardware.camera2.CameraManager
|
||||
import android.hardware.camera2.CameraCharacteristics
|
||||
import android.hardware.camera2.params.OutputConfiguration
|
||||
import android.hardware.camera2.params.SessionConfiguration
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.Surface
|
||||
import android.view.WindowManager
|
||||
import com.nothing.camera2magic.GlobalState
|
||||
import com.nothing.camera2magic.MagicHook
|
||||
import com.nothing.camera2magic.hook.NativeBridge.needStartRenderer
|
||||
import com.nothing.camera2magic.hook.NativeBridge.needStopRenderer
|
||||
import com.nothing.camera2magic.hook.NativeBridge.registerSurfaceIfNew
|
||||
import com.nothing.camera2magic.hook.NativeBridge.releaseLastRegisteredSurface
|
||||
|
||||
import com.nothing.camera2magic.utils.Dog
|
||||
|
||||
import io.github.libxposed.api.XposedModuleInterface.PackageReadyParam
|
||||
import java.lang.ref.WeakReference
|
||||
import java.util.Collections
|
||||
import java.util.WeakHashMap
|
||||
|
||||
object Camera2Hooker {
|
||||
private const val TAG = "[CAM2]"
|
||||
|
||||
private val CameraDevice?.shortId : String
|
||||
get() = if (this == null) "null" else "@0x${Integer.toHexString(System.identityHashCode(this))}"
|
||||
private lateinit var magic: MagicHook
|
||||
private val hookedClasses = Collections.synchronizedSet(
|
||||
Collections.newSetFromMap(WeakHashMap<Class<*>, Boolean>()))
|
||||
private var activeCameraRef: WeakReference<Any>? = null
|
||||
private var cameraState = WeakHashMap<CameraDevice, CameraState>()
|
||||
private fun getCameraState(camera: CameraDevice): CameraState {
|
||||
return synchronized(cameraState) {
|
||||
cameraState.getOrPut(camera) { CameraState() }
|
||||
}
|
||||
}
|
||||
private fun CameraState.saveCameraInfo(camera: CameraDevice) {
|
||||
val cameraIdStr = camera.id
|
||||
val context = GlobalState.appContext
|
||||
val cm = context.getSystemService(Context.CAMERA_SERVICE) as CameraManager
|
||||
val characteristics = cm.getCameraCharacteristics(cameraIdStr)
|
||||
|
||||
val wm = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||||
@Suppress("DEPRECATION")
|
||||
val rotation = wm.defaultDisplay.rotation
|
||||
|
||||
this.apiLevel = 2
|
||||
this.sensorOrientation = characteristics.get(CameraCharacteristics.SENSOR_ORIENTATION) ?: 90
|
||||
this.facingFront = characteristics.get(CameraCharacteristics.LENS_FACING) == CameraCharacteristics.LENS_FACING_FRONT
|
||||
this.displayOrientation = rotation * 90
|
||||
this.packageName = GlobalState.packageName
|
||||
}
|
||||
private fun CameraState.bindSurface(surface: Surface) {
|
||||
val (width, height, _) = NativeBridge.getSurfaceInfo(surface)
|
||||
// Keep the largest surface as the primary reference for VideoPusher resolution
|
||||
if (width * height > this.previewWidth * this.previewHeight) {
|
||||
this.previewWidth = width
|
||||
this.previewHeight = height
|
||||
this.pictureWidth = width
|
||||
this.pictureHeight = height
|
||||
}
|
||||
this.surfaces.add(surface)
|
||||
}
|
||||
private fun handleStateCallback(callback: CameraCaptureSession.StateCallback) {
|
||||
val clazz = callback.javaClass
|
||||
if (hookedClasses.add(clazz)) {
|
||||
val onConfigured = clazz.getDeclaredMethod("onConfigured",
|
||||
CameraCaptureSession::class.java)
|
||||
|
||||
magic.hook(onConfigured).intercept { chain ->
|
||||
val session = chain.args[0] as CameraCaptureSession
|
||||
val camera = session.device
|
||||
val state = getCameraState(camera)
|
||||
|
||||
SourceManager.refreshAndDispatch()
|
||||
|
||||
Handler(Looper.getMainLooper()).postDelayed({
|
||||
val videoId = SourceManager.getVideoId()
|
||||
if (videoId != -1L && state.surfaces.isNotEmpty()) {
|
||||
Dog.i(TAG, "Starting VideoPusher on ${state.surfaces.size} surfaces", true)
|
||||
VideoPusher.start(state.surfaces, state.previewWidth, state.previewHeight, videoId)
|
||||
}
|
||||
}, 100)
|
||||
chain.proceed()
|
||||
}
|
||||
|
||||
val onConfigureFailed = clazz.getDeclaredMethod("onConfigureFailed",
|
||||
CameraCaptureSession::class.java)
|
||||
|
||||
magic.hook(onConfigureFailed).intercept { chain ->
|
||||
Dog.e(TAG, "CameraCaptureSession.StateCallback: onConfigureFailed.", null, true)
|
||||
BlackHoleMapper.clearAll()
|
||||
activeCameraRef = null
|
||||
chain.proceed()
|
||||
}
|
||||
}
|
||||
}
|
||||
@SuppressLint("PrivateApi")
|
||||
fun initHooks(module: MagicHook, param: PackageReadyParam) {
|
||||
magic = module
|
||||
val classLoader = param.classLoader
|
||||
val deviceImplClass = classLoader.loadClass("android.hardware.camera2.impl.CameraDeviceImpl")
|
||||
deviceImplClass.apply {
|
||||
hookCreateCaptureSessionWithConfiguration()
|
||||
hookCreateCaptureSessionWithSurfaces()
|
||||
hookCreateCaptureSessionByOutputConfigurations()
|
||||
hookClose()
|
||||
}
|
||||
|
||||
// Android 14+ CameraDeviceSetup support
|
||||
try {
|
||||
val setupClass = classLoader.loadClass("android.hardware.camera2.impl.CameraDeviceSetupImpl")
|
||||
setupClass.apply {
|
||||
hookCreateCaptureSessionWithConfiguration()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Class might not exist on older versions
|
||||
}
|
||||
|
||||
val builderClass = classLoader.loadClass("android.hardware.camera2.CaptureRequest\$Builder")
|
||||
builderClass.apply {
|
||||
hookAddTarget()
|
||||
hookRemoveTarget()
|
||||
}
|
||||
}
|
||||
|
||||
private fun Class<*>.hookCreateCaptureSessionWithConfiguration() {
|
||||
val method = try {
|
||||
getDeclaredMethod("createCaptureSession", SessionConfiguration::class.java)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
method?.let { m ->
|
||||
magic.hook(m).intercept { chain ->
|
||||
Dog.i(TAG, "[CANARY] createCaptureSession(SessionConfiguration) enter", true)
|
||||
if (!SourceManager.isReadyForHook()) return@intercept chain.proceed()
|
||||
|
||||
try {
|
||||
val camera = chain.thisObject as CameraDevice
|
||||
activeCameraRef = WeakReference(camera)
|
||||
val state = getCameraState(camera)
|
||||
state.saveCameraInfo(camera)
|
||||
BlackHoleMapper.clearAll()
|
||||
|
||||
val sessionConfiguration = chain.args[0] as SessionConfiguration
|
||||
Dog.i(TAG, "processing ${sessionConfiguration.outputConfigurations.size} configurations", true)
|
||||
|
||||
@SuppressLint("SoonBlockedPrivateApi")
|
||||
val field = OutputConfiguration::class.java.getDeclaredField("mSurfaces")
|
||||
field.isAccessible = true
|
||||
sessionConfiguration.outputConfigurations.forEach { outputConfiguration ->
|
||||
var modified = false
|
||||
val surfaces = outputConfiguration.surfaces
|
||||
val modifiedSurfaces = surfaces.mapTo(ArrayList<Surface>()) { origin ->
|
||||
val (w, h, f) = NativeBridge.getSurfaceInfo(origin)
|
||||
Dog.i(TAG, "Surface info: origin=$origin, w=$w, h=$h, f=$f", true)
|
||||
|
||||
if (f == 1 || f == 34 || f == 35 || f == 4 || w > 0) {
|
||||
modified = true
|
||||
val blackHoleSurface = BlackHoleMapper.createBlackHole(origin)
|
||||
Dog.i(TAG, "REPLACED surface $origin with $blackHoleSurface (format $f)", true)
|
||||
state.bindSurface(origin)
|
||||
return@mapTo blackHoleSurface
|
||||
}
|
||||
origin
|
||||
}
|
||||
if (modified) field.set(outputConfiguration, modifiedSurfaces)
|
||||
}
|
||||
handleStateCallback(sessionConfiguration.stateCallback)
|
||||
} catch (e: Exception) {
|
||||
Dog.e(TAG, "Error in createCaptureSession(SessionConfiguration) hook", e, true)
|
||||
}
|
||||
chain.proceed()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Class<*>.hookCreateCaptureSessionWithSurfaces() {
|
||||
val method = try {
|
||||
getDeclaredMethod(
|
||||
"createCaptureSession",
|
||||
List::class.java,
|
||||
CameraCaptureSession.StateCallback::class.java,
|
||||
Handler::class.java)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
method?.let { m ->
|
||||
magic.hook(m).intercept { chain ->
|
||||
Dog.i(TAG, "[CANARY] createCaptureSession(List, Callback, Handler) enter", true)
|
||||
if (!SourceManager.isReadyForHook()) return@intercept chain.proceed()
|
||||
|
||||
try {
|
||||
val camera = chain.thisObject as CameraDevice
|
||||
val state = getCameraState(camera)
|
||||
activeCameraRef = WeakReference(camera)
|
||||
BlackHoleMapper.clearAll()
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val surfaces = chain.args[0] as List<Surface>
|
||||
Dog.i(TAG, "processing ${surfaces.size} surfaces", true)
|
||||
|
||||
val newList = surfaces.mapTo(ArrayList()) { origin ->
|
||||
val (w, h, f) = NativeBridge.getSurfaceInfo(origin)
|
||||
Dog.i(TAG, "Surface info: origin=$origin, w=$w, h=$h, f=$f", true)
|
||||
|
||||
if (f == 1 || f == 34 || f == 35 || f == 4 || w > 0) {
|
||||
val blackHoleSurface = BlackHoleMapper.createBlackHole(origin)
|
||||
Dog.i(TAG, "REPLACED surface $origin with $blackHoleSurface (format $f)", true)
|
||||
// Bind the ORIGINAL surface to the native renderer
|
||||
state.bindSurface(origin)
|
||||
NativeBridge.registerSurfaceIfNew(state, true)
|
||||
return@mapTo blackHoleSurface
|
||||
}
|
||||
origin
|
||||
}
|
||||
|
||||
val stateCallback = chain.args[1] as CameraCaptureSession.StateCallback
|
||||
handleStateCallback(stateCallback)
|
||||
|
||||
val newArgs = chain.args.toTypedArray()
|
||||
newArgs[0] = newList
|
||||
return@intercept chain.proceed(newArgs)
|
||||
} catch (e: Exception) {
|
||||
Dog.e(TAG, "Error in createCaptureSession(List) hook", e, true)
|
||||
}
|
||||
chain.proceed()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Class<*>.hookCreateCaptureSessionByOutputConfigurations() {
|
||||
val method = try {
|
||||
getDeclaredMethod(
|
||||
"createCaptureSessionByOutputConfigurations",
|
||||
List::class.java,
|
||||
CameraCaptureSession.StateCallback::class.java,
|
||||
Handler::class.java)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
method?.let { m ->
|
||||
magic.hook(m).intercept { chain ->
|
||||
Dog.i(TAG, "[CANARY] createCaptureSessionByOutputConfigurations enter", true)
|
||||
if (!SourceManager.isReadyForHook()) return@intercept chain.proceed()
|
||||
|
||||
try {
|
||||
val camera = chain.thisObject as CameraDevice
|
||||
val state = getCameraState(camera)
|
||||
activeCameraRef = WeakReference(camera)
|
||||
BlackHoleMapper.clearAll()
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val configs = chain.args[0] as List<OutputConfiguration>
|
||||
Dog.i(TAG, "processing ${configs.size} output configurations", true)
|
||||
|
||||
val field = OutputConfiguration::class.java.getDeclaredField("mSurfaces")
|
||||
field.isAccessible = true
|
||||
|
||||
configs.forEach { outputConfiguration ->
|
||||
var modified = false
|
||||
val surfaces = outputConfiguration.surfaces
|
||||
val modifiedSurfaces = surfaces.mapTo(ArrayList<Surface>()) { origin ->
|
||||
val (w, h, f) = NativeBridge.getSurfaceInfo(origin)
|
||||
Dog.i(TAG, "Surface info: origin=$origin, w=$w, h=$h, f=$f", true)
|
||||
|
||||
if (f == 1 || f == 34 || f == 35 || f == 4 || w > 0) {
|
||||
modified = true
|
||||
val blackHoleSurface = BlackHoleMapper.createBlackHole(origin)
|
||||
Dog.i(TAG, "REPLACED surface $origin with $blackHoleSurface (format $f)", true)
|
||||
// Bind the ORIGINAL surface to the native renderer
|
||||
state.bindSurface(origin)
|
||||
NativeBridge.registerSurfaceIfNew(state, true)
|
||||
return@mapTo blackHoleSurface
|
||||
}
|
||||
origin
|
||||
}
|
||||
if (modified) field.set(outputConfiguration, modifiedSurfaces)
|
||||
}
|
||||
|
||||
val stateCallback = chain.args[1] as CameraCaptureSession.StateCallback
|
||||
handleStateCallback(stateCallback)
|
||||
} catch (e: Exception) {
|
||||
Dog.e(TAG, "Error in createCaptureSessionByOutputConfigurations hook", e, true)
|
||||
}
|
||||
chain.proceed()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Class<*>.hookClose() {
|
||||
val close = getMethod("close")
|
||||
magic.hook(close).intercept { chain ->
|
||||
val activeCamera = activeCameraRef?.get() as? CameraDevice
|
||||
val closingCamera = chain.thisObject as CameraDevice
|
||||
|
||||
if (activeCamera != null && closingCamera === activeCamera) {
|
||||
Dog.i(TAG, "camera[${closingCamera.shortId}] close.", true)
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
VideoPusher.stop()
|
||||
}
|
||||
// Don't clear all immediately to avoid switch crash
|
||||
activeCameraRef = null
|
||||
}
|
||||
chain.proceed()
|
||||
}
|
||||
}
|
||||
|
||||
private fun Class<*>.hookAddTarget() {
|
||||
val addTarget = getDeclaredMethod("addTarget", Surface::class.java)
|
||||
magic.hook(addTarget).intercept { chain ->
|
||||
val origin = chain.args[0] as Surface
|
||||
val blackHole = BlackHoleMapper.getBlackHole(origin)
|
||||
if (!SourceManager.isReadyForHook() || blackHole == null) {
|
||||
return@intercept chain.proceed()
|
||||
}
|
||||
chain.proceed(arrayOf(blackHole))
|
||||
}
|
||||
}
|
||||
|
||||
private fun Class<*>.hookRemoveTarget() {
|
||||
val removeTarget = getDeclaredMethod("removeTarget", Surface::class.java)
|
||||
magic.hook(removeTarget).intercept { chain ->
|
||||
val origin = chain.args[0] as Surface
|
||||
val blackHole = BlackHoleMapper.getBlackHole(origin)
|
||||
if (!SourceManager.isReadyForHook() || blackHole == null) {
|
||||
return@intercept chain.proceed()
|
||||
}
|
||||
chain.proceed(arrayOf(blackHole))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,524 +0,0 @@
|
||||
package com.nothing.camera2magic.hook
|
||||
|
||||
import android.hardware.camera2.CameraCaptureSession
|
||||
import android.hardware.camera2.CameraDevice
|
||||
import android.hardware.camera2.CameraCaptureSession.StateCallback
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import com.nothing.camera2magic.GlobalState
|
||||
import com.nothing.camera2magic.hook.NativeBridge
|
||||
import com.nothing.camera2magic.utils.Dog
|
||||
import com.nothing.camera2magic.hook.SourceManager
|
||||
import com.nothing.camera2magic.hook.VideoPusher
|
||||
import com.nothing.camera2magic.hook.BlackHoleMapper
|
||||
import com.nothing.camera2magic.hook.CameraState
|
||||
import io.github.libxposed.api.XposedModuleInterface.PackageReadyParam
|
||||
import java.lang.ref.WeakReference
|
||||
import java.util.Collections
|
||||
import java.util.WeakHashMap
|
||||
import kotlin.math.min
|
||||
import android.annotation.SuppressLint
|
||||
import android.view.Surface
|
||||
import android.hardware.camera2.params.OutputConfiguration
|
||||
import android.hardware.camera2.params.SessionConfiguration
|
||||
import com.nothing.camera2magic.MagicHook
|
||||
|
||||
/**
|
||||
* Framework-level hooker for CameraDeviceImpl.
|
||||
* Handles system-wide camera session creation interception.
|
||||
*/
|
||||
object CameraDeviceImplHooker {
|
||||
private const val TAG = "[CAMDEV-IMPL-HK]"
|
||||
private lateinit var magic: MagicHook
|
||||
private val hookedClasses = Collections.synchronizedSet(
|
||||
Collections.newSetFromMap(WeakHashMap<Class<*>, Boolean>()))
|
||||
private var activeCameraRef: WeakReference<Any>? = null
|
||||
private val cameraState = WeakHashMap<CameraDevice, CameraState>()
|
||||
|
||||
fun initHooks(module: MagicHook, param: PackageReadyParam) {
|
||||
Dog.i(TAG, "initHooks called", true)
|
||||
magic = module
|
||||
val classLoader = param.classLoader
|
||||
try {
|
||||
// Access the framework class to prepare for hooking
|
||||
Dog.i(TAG, "Loading CameraDeviceImpl class", true)
|
||||
val deviceImplClass = classLoader.loadClass("android.hardware.camera2.impl.CameraDeviceImpl")
|
||||
Dog.i(TAG, "CameraDeviceImpl loaded successfully", true)
|
||||
deviceImplClass.apply {
|
||||
Dog.i(TAG, "Hooking createCaptureSession variants", true)
|
||||
// Register each hook independently to avoid one failing blocking others
|
||||
try {
|
||||
hookCreateCaptureSessionWithSurfaces()
|
||||
} catch (e: Exception) {
|
||||
Dog.e(TAG, "Failed to register hook: createCaptureSessionWithSurfaces", e, true)
|
||||
}
|
||||
|
||||
try {
|
||||
hookCreateCaptureSessionByOutputConfigurations()
|
||||
} catch (e: Exception) {
|
||||
Dog.e(TAG, "Failed to register hook: createCaptureSessionByOutputConfigurations", e, true)
|
||||
}
|
||||
|
||||
try {
|
||||
hookCreateCaptureSessionWithConfiguration()
|
||||
} catch (e: Exception) {
|
||||
Dog.e(TAG, "Failed to register hook: createCaptureSessionWithConfiguration", e, true)
|
||||
}
|
||||
|
||||
try {
|
||||
hookClose()
|
||||
} catch (e: Exception) {
|
||||
Dog.e(TAG, "Failed to register hook: close", e, true)
|
||||
}
|
||||
|
||||
Dog.i(TAG, "Hook registration attempts finished", true)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Best-effort: do not crash module loading if framework class is unavailable
|
||||
Dog.e(TAG, "Failed to access CameraDeviceImpl", e, true)
|
||||
}
|
||||
|
||||
// Android 14+ CameraDeviceSetup support
|
||||
try {
|
||||
val setupClass = classLoader.loadClass("android.hardware.camera2.impl.CameraDeviceSetupImpl")
|
||||
setupClass.apply {
|
||||
try {
|
||||
hookCreateCaptureSessionWithConfiguration()
|
||||
} catch (e: Exception) {
|
||||
Dog.e(TAG, "Failed to register hook: createCaptureSessionWithConfiguration in CameraDeviceSetupImpl", e, true)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Class might not exist on older versions
|
||||
}
|
||||
|
||||
// Hook CaptureRequest.Builder methods
|
||||
try {
|
||||
val builderClass = classLoader.loadClass("android.hardware.camera2.CaptureRequest\$Builder")
|
||||
builderClass.apply {
|
||||
try {
|
||||
hookAddTarget()
|
||||
} catch (e: Exception) {
|
||||
Dog.e(TAG, "Failed to register hook: addTarget", e, true)
|
||||
}
|
||||
try {
|
||||
hookRemoveTarget()
|
||||
} catch (e: Exception) {
|
||||
Dog.e(TAG, "Failed to register hook: removeTarget", e, true)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Dog.e(TAG, "Failed to load CaptureRequest.Builder", e, true)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Class<*>.hookCreateCaptureSessionWithSurfaces() {
|
||||
val method = try {
|
||||
getDeclaredMethod(
|
||||
"createCaptureSession",
|
||||
List::class.java,
|
||||
CameraCaptureSession.StateCallback::class.java,
|
||||
Handler::class.java)
|
||||
} catch (e: Exception) {
|
||||
Dog.e(TAG, "Could not locate hook method for createCaptureSession(List, StateCallback, Handler)", e, true)
|
||||
null
|
||||
}
|
||||
method?.let { m ->
|
||||
magic.hook(m).intercept { chain ->
|
||||
BlackHoleMapper.clearAll()
|
||||
Dog.i(TAG, "[SYSTEM] createCaptureSession(List, Callback, Handler) enter", true)
|
||||
Dog.i(TAG, "isReadyForHook check", true)
|
||||
if (!SourceManager.isReadyForHook()) return@intercept chain.proceed()
|
||||
|
||||
try {
|
||||
val camera = chain.thisObject as CameraDevice
|
||||
val state = getCameraState(camera)
|
||||
activeCameraRef = WeakReference(camera)
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val surfaces = chain.args[0] as List<Surface>
|
||||
Dog.i(TAG, "Processing ${surfaces.size} surfaces", true)
|
||||
|
||||
val newList = surfaces.mapTo(ArrayList()) { origin ->
|
||||
val (w, h, f) = NativeBridge.getSurfaceInfo(origin)
|
||||
Dog.i(TAG, "Surface info: origin=$origin, w=$w, h=$h, f=$f", true)
|
||||
|
||||
if (f == 1 || f == 34 || f == 35 || f == 4 || w > 0) {
|
||||
val blackHoleSurface = BlackHoleMapper.createBlackHole(origin)
|
||||
Dog.i(TAG, "REPLACED surface $origin with $blackHoleSurface (format $f)", true)
|
||||
state.bindSurface(origin)
|
||||
return@mapTo blackHoleSurface
|
||||
}
|
||||
origin
|
||||
}
|
||||
|
||||
val stateCallback = chain.args[1] as StateCallback
|
||||
Dog.i(TAG, "About to call handleStateCallback", true)
|
||||
try {
|
||||
handleStateCallback(stateCallback)
|
||||
Dog.i(TAG, "handleStateCallback completed", true)
|
||||
} catch (e: Exception) {
|
||||
Dog.e(TAG, "handleStateCallback failed", e, true)
|
||||
}
|
||||
Dog.i(TAG, "About to call checkAndStartVideoPusherFallback", true)
|
||||
try {
|
||||
// Fallback to start VideoPusher if onConfigured doesn't fire
|
||||
checkAndStartVideoPusherFallback(camera, state)
|
||||
Dog.i(TAG, "checkAndStartVideoPusherFallback completed", true)
|
||||
} catch (e: Exception) {
|
||||
Dog.e(TAG, "checkAndStartVideoPusherFallback failed", e, true)
|
||||
}
|
||||
|
||||
val newArgs = chain.args.toTypedArray()
|
||||
newArgs[0] = newList
|
||||
return@intercept chain.proceed(newArgs)
|
||||
} catch (e: Exception) {
|
||||
Dog.e(TAG, "Error in createCaptureSession(List) hook", e, true)
|
||||
return@intercept chain.proceed()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Class<*>.hookCreateCaptureSessionByOutputConfigurations() {
|
||||
val method = try {
|
||||
getDeclaredMethod(
|
||||
"createCaptureSessionByOutputConfigurations",
|
||||
List::class.java,
|
||||
CameraCaptureSession.StateCallback::class.java,
|
||||
Handler::class.java)
|
||||
} catch (e: Exception) {
|
||||
Dog.e(TAG, "Could not locate hook method for createCaptureSessionByOutputConfigurations", e, true)
|
||||
null
|
||||
}
|
||||
method?.let { m ->
|
||||
magic.hook(m).intercept { chain ->
|
||||
BlackHoleMapper.clearAll()
|
||||
Dog.i(TAG, "[SYSTEM] createCaptureSessionByOutputConfigurations enter", true)
|
||||
Dog.i(TAG, "isReadyForHook check", true)
|
||||
if (!SourceManager.isReadyForHook()) return@intercept chain.proceed()
|
||||
|
||||
try {
|
||||
val camera = chain.thisObject as CameraDevice
|
||||
val state = getCameraState(camera)
|
||||
activeCameraRef = WeakReference(camera)
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val configs = chain.args[0] as List<OutputConfiguration>
|
||||
Dog.i(TAG, "Processing ${configs.size} output configurations", true)
|
||||
|
||||
// Create new OutputConfiguration objects instead of modifying existing ones
|
||||
val newConfigs = configs.map { oc ->
|
||||
var modified = false
|
||||
val surfaces = oc.surfaces
|
||||
val modifiedSurfaces = surfaces.mapTo(ArrayList<Surface>()) { origin ->
|
||||
val (w, h, f) = NativeBridge.getSurfaceInfo(origin)
|
||||
Dog.i(TAG, "Surface info: origin=$origin, w=$w, h=$h, f=$f", true)
|
||||
|
||||
if (f == 1 || f == 34 || f == 35 || f == 4 || w > 0) {
|
||||
modified = true
|
||||
val blackHoleSurface = BlackHoleMapper.createBlackHole(origin)
|
||||
Dog.i(TAG, "REPLACED surface $origin with $blackHoleSurface (format $f)", true)
|
||||
state.bindSurface(origin)
|
||||
return@mapTo blackHoleSurface
|
||||
}
|
||||
origin
|
||||
}
|
||||
|
||||
// If any surface was modified, create a new OutputConfiguration
|
||||
if (modified) {
|
||||
val newOc = OutputConfiguration(modifiedSurfaces[0])
|
||||
for (i in 1 until modifiedSurfaces.size) {
|
||||
newOc.addSurface(modifiedSurfaces[i])
|
||||
}
|
||||
newOc
|
||||
} else {
|
||||
// No modification needed, keep original
|
||||
oc
|
||||
}
|
||||
}
|
||||
|
||||
val stateCallback = chain.args[1] as StateCallback
|
||||
Dog.i(TAG, "About to call handleStateCallback", true)
|
||||
try {
|
||||
handleStateCallback(stateCallback)
|
||||
Dog.i(TAG, "handleStateCallback completed", true)
|
||||
} catch (e: Exception) {
|
||||
Dog.e(TAG, "handleStateCallback failed", e, true)
|
||||
}
|
||||
Dog.i(TAG, "About to call checkAndStartVideoPusherFallback", true)
|
||||
try {
|
||||
// Fallback to start VideoPusher if onConfigured doesn't fire
|
||||
checkAndStartVideoPusherFallback(camera, state)
|
||||
Dog.i(TAG, "checkAndStartVideoPusherFallback completed", true)
|
||||
} catch (e: Exception) {
|
||||
Dog.e(TAG, "checkAndStartVideoPusherFallback failed", e, true)
|
||||
}
|
||||
|
||||
// Proceed with new configurations
|
||||
val newArgs = chain.args.toTypedArray()
|
||||
newArgs[0] = newConfigs
|
||||
return@intercept chain.proceed(newArgs)
|
||||
} catch (e: Exception) {
|
||||
Dog.e(TAG, "Error in createCaptureSessionByOutputConfigurations hook", e, true)
|
||||
return@intercept chain.proceed()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Class<*>.hookCreateCaptureSessionWithConfiguration() {
|
||||
val method = try {
|
||||
getDeclaredMethod("createCaptureSession", SessionConfiguration::class.java)
|
||||
} catch (e: Exception) {
|
||||
Dog.e(TAG, "Could not locate hook method for createCaptureSession(SessionConfiguration)", e, true)
|
||||
null
|
||||
}
|
||||
method?.let { m ->
|
||||
magic.hook(m).intercept { chain ->
|
||||
BlackHoleMapper.clearAll()
|
||||
Dog.i(TAG, "[SYSTEM] createCaptureSession(SessionConfiguration) enter", true)
|
||||
Dog.i(TAG, "isReadyForHook check", true)
|
||||
if (!SourceManager.isReadyForHook()) return@intercept chain.proceed()
|
||||
|
||||
try {
|
||||
val camera = chain.thisObject as CameraDevice
|
||||
val state = getCameraState(camera)
|
||||
activeCameraRef = WeakReference(camera)
|
||||
|
||||
val sessionConfiguration = chain.args[0] as SessionConfiguration
|
||||
Dog.i(TAG, "Processing ${sessionConfiguration.outputConfigurations.size} configurations", true)
|
||||
|
||||
// Create new OutputConfiguration objects instead of modifying existing ones
|
||||
val newConfigs = sessionConfiguration.outputConfigurations.map { oc ->
|
||||
var modified = false
|
||||
val surfaces = oc.surfaces
|
||||
val modifiedSurfaces = surfaces.mapTo(ArrayList<Surface>()) { origin ->
|
||||
val (w, h, f) = NativeBridge.getSurfaceInfo(origin)
|
||||
Dog.i(TAG, "Surface info: origin=$origin, w=$w, h=$h, f=$f", true)
|
||||
|
||||
if (f == 1 || f == 34 || f == 35 || f == 4 || w > 0) {
|
||||
modified = true
|
||||
val blackHoleSurface = BlackHoleMapper.createBlackHole(origin)
|
||||
Dog.i(TAG, "REPLACED surface $origin with $blackHoleSurface (format $f)", true)
|
||||
state.bindSurface(origin)
|
||||
return@mapTo blackHoleSurface
|
||||
}
|
||||
origin
|
||||
}
|
||||
|
||||
// If any surface was modified, create a new OutputConfiguration
|
||||
if (modified) {
|
||||
val newOc = OutputConfiguration(modifiedSurfaces[0])
|
||||
for (i in 1 until modifiedSurfaces.size) {
|
||||
newOc.addSurface(modifiedSurfaces[i])
|
||||
}
|
||||
newOc
|
||||
} else {
|
||||
// No modification needed, keep original
|
||||
oc
|
||||
}
|
||||
}
|
||||
|
||||
// Create new SessionConfiguration with new OutputConfigurations
|
||||
// Use the original executor from the session configuration
|
||||
val originalExecutor = try {
|
||||
val execField = SessionConfiguration::class.java.getDeclaredField("mExecutor")
|
||||
execField.isAccessible = true
|
||||
execField.get(sessionConfiguration) as? java.util.concurrent.Executor
|
||||
} catch (e: Exception) {
|
||||
Dog.w(TAG, "Could not get original executor from SessionConfiguration", true)
|
||||
null
|
||||
}
|
||||
val newSessionConfig = if (originalExecutor != null) {
|
||||
SessionConfiguration(
|
||||
sessionConfiguration.sessionType,
|
||||
ArrayList<OutputConfiguration>(newConfigs),
|
||||
originalExecutor,
|
||||
sessionConfiguration.stateCallback
|
||||
)
|
||||
} else {
|
||||
SessionConfiguration(
|
||||
sessionConfiguration.sessionType,
|
||||
ArrayList<OutputConfiguration>(newConfigs)
|
||||
)
|
||||
}
|
||||
|
||||
Dog.i(TAG, "About to call handleStateCallback", true)
|
||||
try {
|
||||
handleStateCallback(sessionConfiguration.stateCallback)
|
||||
Dog.i(TAG, "handleStateCallback completed", true)
|
||||
} catch (e: Exception) {
|
||||
Dog.e(TAG, "handleStateCallback failed", e, true)
|
||||
}
|
||||
Dog.i(TAG, "About to call checkAndStartVideoPusherFallback", true)
|
||||
try {
|
||||
// Fallback to start VideoPusher if onConfigured doesn't fire
|
||||
checkAndStartVideoPusherFallback(camera, state)
|
||||
Dog.i(TAG, "checkAndStartVideoPusherFallback completed", true)
|
||||
} catch (e: Exception) {
|
||||
Dog.e(TAG, "checkAndStartVideoPusherFallback failed", e, true)
|
||||
}
|
||||
|
||||
// Proceed with new session configuration
|
||||
val newArgs = chain.args.toTypedArray()
|
||||
newArgs[0] = newSessionConfig
|
||||
return@intercept chain.proceed(newArgs)
|
||||
} catch (e: Exception) {
|
||||
Dog.e(TAG, "Error in createCaptureSession(SessionConfiguration) hook", e, true)
|
||||
return@intercept chain.proceed()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getCameraState(camera: CameraDevice): CameraState {
|
||||
return synchronized(cameraState) {
|
||||
cameraState.getOrPut(camera) { CameraState() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun CameraState.bindSurface(surface: Surface) {
|
||||
val (width, height, _) = NativeBridge.getSurfaceInfo(surface)
|
||||
// Keep the largest surface as the primary reference for VideoPusher resolution
|
||||
if (width * height > this.previewWidth * this.previewHeight) {
|
||||
this.previewWidth = width
|
||||
this.previewHeight = height
|
||||
this.pictureWidth = width
|
||||
this.pictureHeight = height
|
||||
}
|
||||
this.surfaces.add(surface)
|
||||
}
|
||||
|
||||
private var videoPusherStartedRef = WeakHashMap<CameraDevice, Boolean>()
|
||||
|
||||
private fun handleStateCallback(callback: StateCallback) {
|
||||
@Suppress("ConditionAlwaysTrueFalse")
|
||||
if (callback == null) {
|
||||
Dog.w(TAG, "handleStateCallback: callback is null!", true)
|
||||
return
|
||||
}
|
||||
val clazz = callback.javaClass
|
||||
Dog.i(TAG, "handleStateCallback called for class: ${clazz.simpleName}", true)
|
||||
if (hookedClasses.add(clazz)) {
|
||||
val onConfigured = try {
|
||||
clazz.getDeclaredMethod("onConfigured", CameraCaptureSession::class.java)
|
||||
} catch (e: NoSuchMethodException) {
|
||||
// Try to find inherited method
|
||||
clazz.getMethod("onConfigured", CameraCaptureSession::class.java)
|
||||
}
|
||||
|
||||
magic.hook(onConfigured).intercept { chain ->
|
||||
try {
|
||||
val session = chain.args[0] as CameraCaptureSession
|
||||
val camera = session.device
|
||||
val state = getCameraState(camera)
|
||||
|
||||
Dog.i(TAG, "onConfigured called - session: $session, camera: $camera, surfaces count: ${state.surfaces.size}", true)
|
||||
|
||||
SourceManager.refreshAndDispatch()
|
||||
|
||||
Handler(Looper.getMainLooper()).postDelayed({
|
||||
try {
|
||||
val videoId = SourceManager.getVideoId()
|
||||
Dog.i(TAG, "VideoPusher start check - videoId: $videoId, surfaces not empty: ${state.surfaces.isNotEmpty()}", true)
|
||||
if (videoId != -1L && state.surfaces.isNotEmpty()) {
|
||||
Dog.i(TAG, "Starting VideoPusher on ${state.surfaces.size} surfaces", true)
|
||||
// Protect VideoPusher.start() to avoid propagating errors to the framework
|
||||
VideoPusher.start(state.surfaces, state.previewWidth, state.previewHeight, videoId)
|
||||
videoPusherStartedRef[camera] = true
|
||||
} else {
|
||||
Dog.i(TAG, "NOT starting VideoPusher - videoId: $videoId, surfaces empty: ${state.surfaces.isEmpty()}", true)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Dog.e(TAG, "Error while starting VideoPusher", e, true)
|
||||
}
|
||||
}, 100)
|
||||
chain.proceed()
|
||||
} catch (e: Exception) {
|
||||
Dog.e(TAG, "Error in onConfigured hook", e, true)
|
||||
chain.proceed()
|
||||
}
|
||||
}
|
||||
|
||||
val onConfigureFailed = try {
|
||||
clazz.getDeclaredMethod("onConfigureFailed", CameraCaptureSession::class.java)
|
||||
} catch (e: NoSuchMethodException) {
|
||||
// Try to find inherited method
|
||||
clazz.getMethod("onConfigureFailed", CameraCaptureSession::class.java)
|
||||
}
|
||||
|
||||
magic.hook(onConfigureFailed).intercept { chain ->
|
||||
Dog.e(TAG, "CameraCaptureSession.StateCallback: onConfigureFailed for class: ${clazz.simpleName}", null, true)
|
||||
BlackHoleMapper.clearAll()
|
||||
activeCameraRef = null
|
||||
chain.proceed()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkAndStartVideoPusherFallback(camera: CameraDevice, state: CameraState) {
|
||||
@Suppress("ConditionAlwaysTrueFalse")
|
||||
if (camera == null || state == null) {
|
||||
Dog.w(TAG, "FALLBACK: camera or state is null", true)
|
||||
return
|
||||
}
|
||||
val alreadyStarted = videoPusherStartedRef[camera] ?: false
|
||||
if (!alreadyStarted) {
|
||||
Dog.i(TAG, "FALLBACK: Checking if we should start VideoPusher for camera: $camera", true)
|
||||
Handler(Looper.getMainLooper()).postDelayed({
|
||||
val videoId = SourceManager.getVideoId()
|
||||
Dog.i(TAG, "FALLBACK: VideoPusher check - videoId: $videoId, surfaces not empty: ${state.surfaces.isNotEmpty()}", true)
|
||||
if (videoId != -1L && state.surfaces.isNotEmpty()) {
|
||||
Dog.i(TAG, "FALLBACK: Starting VideoPusher on ${state.surfaces.size} surfaces (onConfigured didn't fire in time)", true)
|
||||
VideoPusher.start(state.surfaces, state.previewWidth, state.previewHeight, videoId)
|
||||
videoPusherStartedRef[camera] = true
|
||||
} else {
|
||||
Dog.i(TAG, "FALLBACK: Not starting VideoPusher - videoId: $videoId, surfaces empty: ${state.surfaces.isEmpty()}", true)
|
||||
}
|
||||
}, 500) // Reduced from 2000ms to 500ms for faster startup
|
||||
}
|
||||
}
|
||||
|
||||
private fun Class<*>.hookClose() {
|
||||
val close = getMethod("close")
|
||||
magic.hook(close).intercept { chain ->
|
||||
val activeCamera = activeCameraRef?.get() as? CameraDevice
|
||||
val closingCamera = chain.thisObject as CameraDevice
|
||||
|
||||
if (activeCamera != null && closingCamera === activeCamera) {
|
||||
Dog.i(TAG, "camera[${System.identityHashCode(closingCamera)}] close.", true)
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
VideoPusher.stop()
|
||||
}
|
||||
// Don't clear all immediately to avoid switch crash
|
||||
activeCameraRef = null
|
||||
}
|
||||
chain.proceed()
|
||||
}
|
||||
}
|
||||
|
||||
private fun Class<*>.hookAddTarget() {
|
||||
val addTarget = getDeclaredMethod("addTarget", Surface::class.java)
|
||||
magic.hook(addTarget).intercept { chain ->
|
||||
val origin = chain.args[0] as Surface
|
||||
val blackHole = BlackHoleMapper.getBlackHole(origin)
|
||||
if (!SourceManager.isReadyForHook() || blackHole == null) {
|
||||
return@intercept chain.proceed()
|
||||
}
|
||||
chain.proceed(arrayOf(blackHole))
|
||||
}
|
||||
}
|
||||
|
||||
private fun Class<*>.hookRemoveTarget() {
|
||||
val removeTarget = getDeclaredMethod("removeTarget", Surface::class.java)
|
||||
magic.hook(removeTarget).intercept { chain ->
|
||||
val origin = chain.args[0] as Surface
|
||||
val blackHole = BlackHoleMapper.getBlackHole(origin)
|
||||
if (!SourceManager.isReadyForHook() || blackHole == null) {
|
||||
return@intercept chain.proceed()
|
||||
}
|
||||
chain.proceed(arrayOf(blackHole))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
package com.nothing.camera2magic.hook
|
||||
|
||||
import com.nothing.camera2magic.MagicHook
|
||||
import com.nothing.camera2magic.utils.Dog
|
||||
import io.github.libxposed.api.XposedModuleInterface.PackageReadyParam
|
||||
|
||||
object WebRTCHooker {
|
||||
private const val TAG = "[WebRTC]"
|
||||
private val ROTATION_REGEX = Regex("""(\d+)x(\d+).*rotation\s+(\d+)""")
|
||||
private lateinit var magic: MagicHook
|
||||
private var manualRotation = 0
|
||||
fun initHooks(module: MagicHook, param: PackageReadyParam) {
|
||||
magic = module
|
||||
val classLoader = param.classLoader
|
||||
|
||||
classLoader.loadClass("org.webrtc.Logging").apply {
|
||||
val nativeLog = getDeclaredMethod("nativeLog",
|
||||
Int::class.java, String::class.java, String::class.java)
|
||||
magic.hook(nativeLog).intercept { chain ->
|
||||
val tag = chain.args[1] as String
|
||||
val msg = chain.args[2] as String
|
||||
if (msg.contains("rotation", ignoreCase = true)) {
|
||||
handleMessage(msg)
|
||||
}
|
||||
if (tag == "Camera2Session" && msg.contains("Stop Camera2 session", ignoreCase = true)) {
|
||||
manualRotation = 0
|
||||
NativeBridge.updateManualRotation(manualRotation)
|
||||
NativeBridge.needStopRenderer()
|
||||
NativeBridge.releaseLastRegisteredSurface()
|
||||
}
|
||||
|
||||
chain.proceed()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleMessage(msg: String) {
|
||||
val matchResult = ROTATION_REGEX.find(msg)
|
||||
matchResult?.let {
|
||||
val (_, _, r) = it.destructured
|
||||
val rotation = r.toInt()
|
||||
if (manualRotation != rotation) {
|
||||
Dog.i(TAG, "WebRTC set rotation: $rotation", SourceManager.enableLog)
|
||||
manualRotation = 90
|
||||
NativeBridge.updateManualRotation(manualRotation)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
com.nothing.camera2magic.MagicHook
|
||||
@@ -1,4 +0,0 @@
|
||||
id=com.nothing.camera2magic
|
||||
minApiVersion=101
|
||||
targetApiVersion=101
|
||||
staticScope=false
|
||||
@@ -1 +0,0 @@
|
||||
camera3
|
||||
@@ -1 +0,0 @@
|
||||
tv.danmaku.bili
|
||||
Reference in New Issue
Block a user