Full but still broken rework of everything

This commit is contained in:
2026-05-08 19:28:57 +02:00
parent e5471b5fa0
commit 3aa0470bcf
56 changed files with 2247 additions and 1552 deletions
+33 -19
View File
@@ -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"
-15
View File
@@ -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
View File
@@ -1 +0,0 @@
camera3
-1
View File
@@ -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--
}
})
}
}
+26 -9
View File
@@ -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))
}
}
@@ -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