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
+1
View File
@@ -15,3 +15,4 @@ local.properties
app/modern app/modern
app/legacy app/legacy
.sisyphus/ .sisyphus/
*.zip
+156
View File
@@ -0,0 +1,156 @@
# CamSwapper — Project Status
## Build Status
- **Both flavors compile**: `modernDebug` and `legacyDebug` assemble successfully with 0 errors, 0 warnings (minor `extractNativeLibs` manifest warning from AGP).
- **On-device test**: Installed on Pixel 9a (Android 16). FGS crash resolved with `specialUse` type. App now has Application class, INTERNET + POST_NOTIFICATIONS permissions, and custom notification icon.
- **Min SDK**: 29 | **Target SDK**: 36 | **Compile SDK**: 36
- **Kotlin**: JVM 11, toolchain 17
- **Gradle**: 8.13
## Recent Changes
1. **Removed Xposed framework dependency** — all Xposed APIs (IXposedHookLoadPackage, ReflectionTools, process-based hooks) deleted.
2. **Cleaned up Xposed remnants** — removed `src/modern/resources/META-INF/xposed/`, `src/legacy/AndroidManifest.xml` (xposed metadata), and `src/legacy/assets/` (xposed_init files).
3. **Moved `ConfigRepository.kt`** from `src/modern/java/` to `src/main/java/` so both product flavors can compile (MediaType/MediaSource enums needed by main source set files).
4. **Rewrote `VirtualCameraService.kt`** (485 corrupted lines → 327 clean lines):
- Uses reflection for `@SystemApi` VDM types (`VirtualDeviceManager`, `VirtualDeviceParams`, `VirtualCameraConfig`, `VirtualCameraCallback`, `DeviceFilter.Builder`)
- Uses direct API for public companion types (`CompanionDeviceManager`, `AssociationRequest`, `AssociationInfo`)
- Single clean `VirtualCameraCallback` proxy wired to `VideoPusher` pipeline
- Proper CDM association flow with fallback to dummy ID
- Removed 4x duplicated callback blocks, broken braces, dangling code
5. **Fixed `foregroundServiceType` for SDK 36** — On-device test on Pixel 9a (Android 16) revealed `mediaProjection` FGS type requires `FOREGROUND_SERVICE_MEDIA_PROJECTION` + `CAPTURE_VIDEO_OUTPUT` permissions on target SDK 36. Changed to `specialUse` with `PROPERTY_SPECIAL_USE_FGS_SUBTYPE` declaration. APK now installs without crash on boot.
6. **Added `CamSwapperApp` Application class**`GlobalState.appContext` was `lateinit` and never initialized, causing potential crash when `VideoPusher.start()` or `SourceManager` accessed it. Now properly initialized in `Application.onCreate()`.
7. **Added missing permissions**`INTERNET` (needed by ExoPlayer/Media3), `POST_NOTIFICATIONS` (for Android 13+ notification permission compliance).
8. **Replaced notification icon**`android.R.drawable.ic_menu_camera` (system resource, may be unavailable) replaced with custom `R.drawable.ic_notification`.
## Architecture Overview
```
app/src/main/java/com/nothing/camera2magic/
├── MainActivity.kt # Entry point, permission flow, service bind
├── VirtualCameraService.kt # Foreground service → VirtualDeviceManager via reflection
├── GlobalState.kt # Global app context holder
├── viewmodel/
│ ├── ConfigRepository.kt # SharedPreferences-backed config (MediaType, MediaSource enums)
│ ├── SpotlightViewModel.kt # Media selection & preview state (MVI + StateFlow)
│ ├── SettingsViewModel.kt # App settings + HAL mode toggle
│ ├── ViewModelFactory.kt # ViewModel provider factory
│ └── CompositionLocals.kt # Compose DI locals
├── view/
│ ├── SpotlightView.kt # Media source selector, preview grid, module switch
│ └── SettingsView.kt # Toggle settings + HAL mode config
├── hook/
│ ├── SourceManager.kt # Media content resolver & fingerprint checking (224 lines)
│ ├── VideoPusher.kt # ExoPlayer → Surface → VirtualCameraRenderer pipeline
│ ├── VirtualCameraRenderer.kt # OpenGL ES renderer, SurfaceTexture → EGL → ImageWriter (387 lines)
│ ├── NativeBridge.kt # JNI bridge for native C++ camera HAL hooks
│ ├── HalConfigManager.kt # Root-based HAL config writer (/data/local/camera_magic/)
│ ├── BlackHoleMapper.kt # Empty/dummy camera HAL mapper
│ └── CameraState.kt # Camera state tracking
├── ui/theme/
│ ├── Color.kt, Theme.kt, Type.kt # Material3 theme
└── utils/
└── Dog.kt # Logging utility
```
## Product Flavors
| Flavor | Purpose |
|---------|---------|
| `legacy` | Devices without VirtualDeviceManager support (Android 10-13) — currently identical to modern |
| `modern` | Devices with Android 14+ VirtualDeviceManager API — has additional hardware buffer path available |
## Pipeline: Media → Virtual Camera
```
Local Media (ContentResolver) or Network (RTSP)
VideoPusher (ExoPlayer) / SourceManager
VirtualCameraRenderer (OpenGL ES)
│ SurfaceTexture → EGL → ImageWriter
VirtualCameraService (reflection)
│ VirtualDeviceManager → VirtualCamera
Other apps see a virtual camera device
```
## Component Details
### VirtualCameraService (reflection-based)
- **Status**: Compiles ✅, refactored ✅ (327 lines, clean architecture)
- **API level gate**: Build.VERSION_CODES.UPSIDE_DOWN_CAKE (34)
- **Approach**: Reflection for `@SystemApi` types (`VirtualDeviceManager`, `VirtualDeviceParams`, `VirtualCameraConfig`, `VirtualCameraCallback`, `DeviceFilter.Builder`). Direct API for public companion types (`CompanionDeviceManager`, `AssociationRequest`, `AssociationInfo`, `DeviceFilter`)
- **Callback**: `VirtualCameraCallback` proxy wired — `onStreamConfigured()` feeds the `Surface` to `VideoPusher.start()`
- **CDM**: Full `CompanionDeviceManager.associate()` flow with `DeviceFilter.FEATURE_VIRTUAL_CAMERA` filter, plus fallback to dummy ID 1 when CDM unavailable or association fails
- **VideoPusher integration**: Uses `VideoPusher` singleton object directly (`VideoPusher.start()`, `VideoPusher.stop()`)
- **Previously**: 485 corrupted lines with 4x duplicated callback blocks, broken braces, dangling code, no callback wiring, `VideoPusher.getInstance()` dependency that didn't exist
### ConfigRepository
- **Status**: Clean ✅
- Backs all settings via SharedPreferences
- Enums: `MediaSource` (LOCAL/NETWORK), `MediaType` (VIDEO/IMAGE)
- HAL mode settings also stored here
### SourceManager
- **Status**: ✅ Compiles, legacy footprint from Xposed era
- Global singleton, manages media source selection & fingerprinting
- Directly accesses ContentResolver for local media
- Still uses `moduleEnabled` concept from Xposed module days
### VideoPusher + VirtualCameraRenderer
- **Status**: ✅ Compiles (OpenGL ES + ExoPlayer pipeline)
- VideoPusher: Singleton object, takes a video ID via `start(surfaces, width, height, videoId)`, creates ExoPlayer, feeds to renderer surfaces
- VirtualCameraRenderer: 387-line OpenGL ES 2.0 renderer, handles EGL setup, SurfaceTexture → ImageWriter pipeline
- VideoPusher is wired via `VirtualCameraCallback.onStreamConfigured()` in VirtualCameraService
### HalConfigManager
- **Status**: ✅ Compiles, requires root
- Writes config to `/data/local/camera_magic/config.txt` via `su -c`
- Called from SettingsViewModel HAL mode toggle
## Remaining Work / Known Issues
### Critical
- (none — all previously identified critical issues resolved)
### Medium
- [ ] **SourceManager duplicates ConfigRepository** — SourceManager has its own SharedPreferences keys and module state, duplicating ConfigRepository. Should be consolidated.
- [ ] **Native bridge stub**`NativeBridge.kt` exists but no native `.so` is loaded (CMake/NDK build disabled). The project uses prebuilt `libcamera3.so` from jniLibs.
- [ ] **legacy flavor** — Both flavors currently have identical source sets. The `legacy` flavor should ideally strip out VirtualDeviceManager-dependent features.
### Low / Cleanup
- [ ] **Test files**`ExampleInstrumentedTest.kt` and `ExampleUnitTest.kt` are the default Android Studio stubs.
- [ ] **GlobalState.kt** — Legacy singleton used by Xposed hooks; may be removable now.
- [ ] **`Dog` utility** — Thin wrapper over `android.util.Log`.
## Module Projects (outside `app/`)
| Directory | Purpose |
|-----------|---------|
| `root-module/` | KernelSU module packaging (HAL-level camera hook) |
| `zygisk-module/` | Zygisk module packaging (injection-based) |
| `native/` | C++ native code for camera HAL hooks |
| `docs/` | Documentation assets |
| `camswapper-kernelsu-v2.zip` | Prebuilt KernelSU module |
| `camswapper-zygisk-v2.zip` | Prebuilt Zygisk module |
## Build Commands
```bash
# Build modern flavor (VirtualDeviceManager + system APIs)
./gradlew :app:assembleModernDebug
# Build legacy flavor (no system APIs)
./gradlew :app:assembleLegacyDebug
# Build with native C++ compilation
./gradlew :app:buildNative
# Build release
./gradlew :app:assembleModernRelease
```
+33 -19
View File
@@ -55,14 +55,13 @@ android {
versionCode versionCodeOffset + gitCommitCount versionCode versionCodeOffset + gitCommitCount
versionName "${majorVersion}.${minorVersion}.${patchVersion}" versionName "${majorVersion}.${minorVersion}.${patchVersion}"
/* // externalNativeBuild disabled - native code not ready yet
externalNativeBuild { // externalNativeBuild {
cmake { // cmake {
cppFlags += "" // cppFlags += ""
arguments "-DANDROID_PLATFORM=android-29" // arguments "-DANDROID_PLATFORM=android-29"
} // }
} // }
*/
ndk { ndk {
abiFilters.add("arm64-v8a") abiFilters.add("arm64-v8a")
@@ -72,10 +71,29 @@ android {
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 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 { buildTypes {
debug {
signingConfig signingConfigs.debug
}
release { release {
minifyEnabled true minifyEnabled true
shrinkResources true shrinkResources true
signingConfig signingConfigs.release
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
} }
} }
@@ -102,14 +120,13 @@ android {
} }
/* // externalNativeBuild disabled - native code not ready yet
externalNativeBuild { // externalNativeBuild {
cmake { // cmake {
path file("src/main/cpp/CMakeLists.txt") // path file("src/main/cpp/CMakeLists.txt")
version = "3.22.1" // version = "3.22.1"
} // }
} // }
*/
buildFeatures { buildFeatures {
compose true compose true
@@ -177,9 +194,6 @@ tasks.register("buildNative", Copy) {
} }
dependencies { 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-compose:2.10.0"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.10.0" implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.10.0"
implementation "com.google.accompanist:accompanist-permissions:0.37.3" 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:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" /> 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 <application
android:name=".CamSwapperApp"
android:allowBackup="true" android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules" android:fullBackupContent="@xml/backup_rules"
@@ -21,15 +35,9 @@
android:description="@string/app_description" android:description="@string/app_description"
android:extractNativeLibs="false"> android:extractNativeLibs="false">
<meta-data <property
android:name="xposedmodule" android:name="android.app.role"
android:value="true" /> android:value="android.role.app_streaming" />
<meta-data
android:name="xposeddescription"
android:value="CamSwapper Virtual Camera Module" />
<meta-data
android:name="xposedminversion"
android:value="100" />
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
@@ -41,5 +49,14 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </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> </application>
</manifest> </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 package com.nothing.camera2magic
import android.Manifest import android.Manifest
import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.IBinder
import android.util.Log import android.util.Log
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
@@ -33,6 +37,21 @@ import com.nothing.camera2magic.viewmodel.ViewModelFactory
class MainActivity : ComponentActivity() { 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) @OptIn(ExperimentalPermissionsApi::class)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -72,6 +91,20 @@ class MainActivity : ComponentActivity() {
} }
} }
} }
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 @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.content.SharedPreferences
import android.util.Log import android.util.Log
import io.github.libxposed.service.XposedService
import io.github.libxposed.service.XposedServiceHelper
import androidx.core.content.edit import androidx.core.content.edit
private const val TAG = "[VCX][ConfigRepo]" 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) { 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) { private fun <T> save(key: String, value: T) {
prefs.edit { prefs.edit {
when (value) { when (value) {
@@ -59,41 +42,6 @@ class ConfigRepository(private val prefs: SharedPreferences) {
else -> throw IllegalArgumentException("Unsupported type") 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 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
+313
View File
@@ -0,0 +1,313 @@
# CamSwapper Status - Android 16 (Pixel 9a / tegu)
**Date**: 2026-05-08
**Device**: Pixel 9a (tegu)
**Android Version**: 16 (API 36)
**Root Solution**: KernelSU with ReZygisk
---
## Executive Summary
**Goal**: Replace physical camera frames with virtual video/RTSP stream at the system level (HAL layer).
**Current Status**: ⚠️ **PARTIALLY WORKING** - Hook library built and Zygisk module loads, but injection into camera processes **FAILS** because cameraserver and camera provider are **init services**, not zygote-forked processes.
---
## Process Architecture (Pixel 9a)
### Camera-Related Processes
| Process | PID | PPID | Type | Can Zygisk Hook? |
|---------|-----|------|------|-------------------|
| `cameraserver` | ~12018 | 1 (init) | **init service** | ❌ NO |
| `camera.provider` | ~12019 | 1 (init) | **init service** | ❌ NO |
| `virtual_camera` | ~12361 | 1 (init) | **init service** | ❌ NO |
| `zygote64` | 958 | 1 (init) | init service | ✅ YES (but irrelevant) |
| `system_server` | varies | zygote64 | zygote-forked | ✅ YES |
### Key Discovery
From `zygisk.hpp` line 30-45:
> "Please note that modules will only be loaded after zygote has forked the child process."
**This means Zygisk CANNOT hook init services.** The cameraserver and camera provider are started by init (PPID=1), not forked from zygote.
---
## What Works
### 1. HAL Hook Library (`libcamera_hook.so`)
- **Built**: 336KB (stripped), ARM64 aarch64
- **Location**: `/data/local/camera_magic/libcamera_hook.so`
- **Functionality**:
- ✅ Intercepts `dlopen`/`dlsym` to hook Camera HAL symbols
- ✅ Implements `CameraProviderHook`, `CameraDeviceHook`, `CameraDeviceSessionHook`
-`inject_video_frame()` implemented (reads from shared memory ring buffer)
- ✅ Video decoder (MediaCodec NDK) → YUV420 → shared memory
- ✅ RTSP client → RTP receive → shared memory
- ✅ Buffer converter (YUV420↔NV21 with NEON)
### 2. Zygisk Module (`camswapper-zygisk-v2.zip`)
- **Properly implemented** using Zygisk API v4
- **File**: `/data/adb/modules/camera-hook-zygisk/zygisk/arm64-v8a.so`
- **Status**: Loaded in ReZygisk (confirmed in `/data/adb/rezygisk/state.json`)
- **Symbols**: `zygisk_module_entry` exported correctly
- **Code**: Uses `zygisk::ModuleBase` with `REGISTER_ZYGISK_MODULE(CamSwapperModule)`
### 3. KernelSU Module (`camswapper-kernelsu-v2.zip`)
- **Contains**: `libcamera_hook.so`, `service.sh`, `post-fs-data.sh`
- **Injection method**: ptrace dlopen (BROKEN - see below)
---
## What Does NOT Work
### 1. ❌ Zygisk Injection Fails for Camera Processes
**Root Cause**: cameraserver and camera.provider are **init services** (PPID=1), not zygote-forked.
**Evidence**:
```bash
$ ps -A -o pid,ppid,cmd | grep camera
12018 1 cameraserver
12019 1 camera.provider
12361 1 virtual_camera
```
**Zygisk module loaded in zygote64**:
```bash
$ cat /proc/zygote64/maps | grep camera-hook
70fa88f000-70fa8c2000 r-xp ... /data/adb/modules/camera-hook-zygisk/zygisk/arm64-v8a.so
```
**But NOT in cameraserver**:
```bash
$ cat /proc/cameraserver/maps | grep camera-hook
(empty - not loaded)
```
**Conclusion**: Zygisk `postServerSpecialize()` only runs for `system_server`, not cameraserver. The `is_camera_process()` check in `zygisk_entry.cpp` never triggers because cameraserver isn't zygote-forked.
---
### 2. ❌ Ptrace dlopen Injection Crashes (SIGSEGV)
**Method**: Attach to cameraserver via ptrace, call `__loader_dlopen` in linker64 to load `libcamera_hook.so`.
**Result**: Crashes with SIGSEGV when loading `libcamera_hook.so` (works for `libc.so`).
**Root Cause**: Linker namespace restrictions on Android 16. The `__loader_dlopen` function requires proper namespace setup that ptrace can't replicate.
**Evidence** (from previous testing):
- dlopen for `libc.so` → returns valid handle (0x2443dbca3ce35c9b)
- dlopen for `libcamera_hook.so` → returns NULL or crashes
- APEX linker namespace blocks `/data/local/tmp` paths
---
### 3. ⚠️ Virtual Camera Service (Potential Solution)
**Service**: `virtual_camera` (PID ~12361)
**Interface**: `android.hardware.camera.provider.ICameraProvider/virtual/0`
**Status**: Running but provides **0 camera devices**
**Evidence**:
```bash
$ dumpsys android.hardware.camera.provider.ICameraProvider/virtual/0
== Camera Provider HAL ... virtual/0-1 (v2.0, remote) static info: 0 devices: ==
```
**Service Definition** (`/system/etc/init/virtual_camera.hal.rc`):
```rc
service virtual_camera /system/bin/virtual_camera
class core
user system
group system
interface aidl virtual_camera
interface aidl android.hardware.camera.provider.ICameraProvider/virtual/0
oneshot
disabled
```
**Key Finding**: The service is "disabled" in RC file but is somehow running. It provides 0 devices, meaning it's not configured with a virtual camera stream.
---
## Attempted Solutions (All Failed)
### 1. `wrap.cameraserver` Property
- **Method**: Set `wrap.cameraserver=/system/bin/cameraserver_wrapper`
- **Result**: ❌ Silently ignored on production build (ro.debuggable=0)
### 2. Bind-Mount Wrapper
- **Method**: Bind-mount wrapper binary over `/system/bin/cameraserver`
- **Result**: ❌ cameraserver crashes (SELinux context or APEX library resolution)
### 3. Ptrace dlopen Injection
- **Result**: ❌ SIGSEGV (linker namespace issue)
### 4. Zygisk Module (API v4)
- **Result**: ❌ Loads in zygote but can't hook init services
### 5. Original `zygisk_entry.cpp` (Wrong API)
- **Problem**: Exported raw C symbols (`zygisk_module_entry`, `pre_app_specialize`, etc.)
- **Fix**: Rewrote to use proper `zygisk::ModuleBase` class
- **Result**: Still can't hook init services (fundamental limitation)
---
## Path Forward (Recommended Approaches)
### Option A: Use `virtual_camera` Service (BEST OPTION)
**Concept**: Instead of hooking existing camera provider, configure Android's built-in `virtual_camera` service to provide virtual camera devices.
**Steps**:
1. Determine how to configure `virtual_camera` to provide a camera device
2. Point it to our video/RTSP stream
3. No injection needed - uses Android's native virtual camera support
**Research Needed**:
- Read AOSP source for `virtual_camera` service
- Find configuration file or binder interface to add virtual camera streams
- Check `IVirtualCameraService` interface (service list shows it exists)
---
### Option B: Hook at Binder IPC Layer
**Concept**: Intercept camera HAL binder calls instead of hooking the process.
**Method**:
1. Use binder hook (via Zygisk or KernelSU)
2. Intercept `ICameraDeviceSession::processCaptureRequest`
3. Replace buffer contents before they reach the camera HAL
**Advantage**: Works regardless of which process handles the camera
---
### Option C: Modify init.rc (Require Reboot + Possible Bootloop)
**Concept**: Add `setenv LD_PRELOAD=/path/to/libcamera_hook.so` to cameraserver service definition.
**Risk**: High - modifying init.rc can cause bootloops on production builds
---
### Option D: Use `virtualizationservice` (Discovered Running)
**Concept**: Pixel 9a runs `virtualizationservice` (PID 6360) which spawns `virtmgr_virtualizationservice` and `virtual_camera`.
**Possibility**: The virtual camera infrastructure is already running - we just need to configure it properly.
---
## File Inventory
### Built Files
| File | Size | Purpose |
|------|------|---------|
| `native/build/libcamera_hook.so` | 336KB | HAL hook library (stripped) |
| `zygisk-module/zygisk/arm64-v8a.so` | 223KB | Zygisk module (proper API v4) |
| `camswapper-kernelsu-v2.zip` | 1.9MB | KernelSU module zip |
| `camswapper-zygisk-v2.zip` | 79KB | Zygisk module zip |
### Source Files
| File | Lines | Purpose |
|------|-------|---------|
| `native/src/camera_wrapper.cpp` | 386 | HAL hook with `inject_video_frame()` |
| `native/src/video_decoder.cpp` | 401 | MediaCodec decoder → shared memory |
| `native/src/rtsp_client.cpp` | 653 | RTSP client → RTP receive |
| `native/src/buffer_converter.cpp` | 173 | YUV420↔NV21 conversion |
| `zygisk-module/zygisk_entry.cpp` | 88 | Zygisk module (API v4) |
| `zygisk-module/jni/zygisk.hpp` | 391 | Zygisk API header |
### Headers
| File | Purpose |
|------|---------|
| `native/include/video_decoder.h` | Ring buffer shared memory definitions |
| `native/include/rtsp_client.h` | RTSP client state structures |
| `native/include/camera_hal/*.h` | AIDL camera HAL interface definitions |
---
## Key Learnings for Future AI
### 1. Always Check Process Parent (PPID)
Before attempting Zygisk injection, run:
```bash
adb shell "ps -A -o pid,ppid,cmd | grep <target_process>"
```
If PPID=1 (init), Zygisk **cannot** hook it.
### 2. Zygisk API v4 Correct Usage
```cpp
#include "zygisk.hpp"
class MyModule : public zygisk::ModuleBase {
void onLoad(Api *api, JNIEnv *env) override { ... }
void postServerSpecialize(const ServerSpecializeArgs *args) override { ... }
};
REGISTER_ZYGISK_MODULE(MyModule)
```
### 3. ReZygisk State File
Check module loading status:
```bash
adb shell "su -c 'cat /data/adb/rezygisk/state.json'"
```
### 4. Virtual Camera Service Exists
On Android 16 (Pixel 9a), `virtual_camera` service is available but disabled by default. It provides 0 devices until configured.
### 5. Ptrace dlopen Broken on Android 16
Linker namespace restrictions prevent ptrace-based dlopen for anything other than system libraries.
---
## Next Session Action Plan
1. **Research `virtual_camera` configuration**:
- Search AOSP source for `virtual_camera` implementation
- Find how to add virtual camera streams
- Check `IVirtualCameraService` binder interface
2. **Test Option A first** (most promising):
- Enable virtual camera with custom stream
- Verify it appears as a camera device
- Point it to video/RTSP source
3. **If Option A fails**, try Option B (binder hook)
4. **Document findings** in this file for future reference
---
## Quick Reference Commands
```bash
# Check camera processes
adb shell "ps -Z | grep -E 'cameraserver|camera.provider|virtual_camera'"
# Check if Zygisk module loaded in process
adb shell "su -c 'cat /proc/\$(pidof zygote64)/maps | grep camera-hook'"
# Check virtual camera status
adb shell "dumpsys android.hardware.camera.provider.ICameraProvider/virtual/0"
# Check binder services
adb shell "service list | grep -i camera"
# View ReZygisk state
adb shell "su -c 'cat /data/adb/rezygisk/state.json'"
# Check module.prop description (updated by service.sh)
adb shell "su -c 'cat /data/adb/modules/camera-hook-zygisk/module.prop'"
```
---
**Last Updated**: 2026-05-08 by Atlas (OhMyOpenCode)
**Session ID**: Compressed conversation (b3, b4, b5, b6, b7, b12, b13, b14, b15, b16, b17)
+3 -3
View File
@@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.22.1) cmake_minimum_required(VERSION 3.25)
project(camera_hook LANGUAGES CXX) project(camera_hook LANGUAGES CXX C)
set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_STANDARD_REQUIRED ON)
@@ -51,7 +51,7 @@ set_target_properties(camera_hook PROPERTIES
# Camera provider wrapper (static C binary, bind-mounted over original) # Camera provider wrapper (static C binary, bind-mounted over original)
add_executable(camera_provider_wrapper src/camera_provider_wrapper.c) add_executable(camera_provider_wrapper src/camera_provider_wrapper.c)
target_link_options(camera_provider_wrapper PRIVATE -static)
set_target_properties(camera_provider_wrapper PROPERTIES set_target_properties(camera_provider_wrapper PROPERTIES
LINKER_LANGUAGE C
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_SOURCE_DIR}/../root-module" RUNTIME_OUTPUT_DIRECTORY "${CMAKE_SOURCE_DIR}/../root-module"
) )
+4
View File
@@ -54,6 +54,10 @@ int get_rtsp_shmem_fd();
int get_rtsp_width(); int get_rtsp_width();
int get_rtsp_height(); int get_rtsp_height();
// Get the shared memory base pointer (for reading frames)
// Returns nullptr if not initialized
uint8_t* get_rtsp_shmem_base();
} // namespace rtsp_client } // namespace rtsp_client
#endif // RTSP_CLIENT_H #endif // RTSP_CLIENT_H
+4
View File
@@ -87,6 +87,10 @@ int get_decoder_width();
int get_decoder_height(); int get_decoder_height();
bool is_decoder_running(); bool is_decoder_running();
// Get the shared memory base pointer (for reading frames)
// Returns nullptr if not initialized
uint8_t* get_shmem_base();
// Write a YUV420 frame to the shared memory ring buffer // Write a YUV420 frame to the shared memory ring buffer
// Called internally by the decoder thread // Called internally by the decoder thread
// y_data, u_data, v_data: pointers to Y, U, V planes // y_data, u_data, v_data: pointers to Y, U, V planes
+58 -6
View File
@@ -1,6 +1,8 @@
#include <dlfcn.h> #include <dlfcn.h>
#include <android/log.h> #include <android/log.h>
#include <android/hardware_buffer.h>
#include <unistd.h> #include <unistd.h>
#include <sys/mman.h>
#include <cstring> #include <cstring>
#include <cstdio> #include <cstdio>
#include <map> #include <map>
@@ -11,10 +13,20 @@
#include <chrono> #include <chrono>
#include <fstream> #include <fstream>
// Define native_handle_t (not publicly available in NDK)
struct native_handle_t {
int version;
int numFds;
int numInts;
int data[0];
};
#include "camera_hal/types.h" #include "camera_hal/types.h"
#include "camera_hal/ICameraProvider.h" #include "camera_hal/ICameraProvider.h"
#include "camera_hal/ICameraDevice.h" #include "camera_hal/ICameraDevice.h"
#include "camera_hal/ICameraDeviceSession.h" #include "camera_hal/ICameraDeviceSession.h"
#include "video_decoder.h"
#include "rtsp_client.h"
#define LOG_TAG "CameraHook" #define LOG_TAG "CameraHook"
#define ALOGD(...) __android_log_print(ANDROID_LOG_DEBUG, LOG_TAG, __VA_ARGS__) #define ALOGD(...) __android_log_print(ANDROID_LOG_DEBUG, LOG_TAG, __VA_ARGS__)
@@ -105,15 +117,55 @@ static bool check_virtual_camera_enabled() {
static bool inject_video_frame(int32_t stream_id, void* buffer_handle, static bool inject_video_frame(int32_t stream_id, void* buffer_handle,
int32_t width, int32_t height) { int32_t width, int32_t height) {
// Task 7/9 will implement: read from shared memory ring buffer,
// lock AHardwareBuffer, copy YUV data, unlock.
// Returns true if frame was injected, false if no frame available.
(void)stream_id; (void)stream_id;
(void)buffer_handle;
(void)width; uint8_t* shmem_base = video_decoder::get_shmem_base();
(void)height; if (!shmem_base) {
shmem_base = rtsp_client::get_rtsp_shmem_base();
if (!shmem_base) {
return false; return false;
} }
}
video_decoder::RingBufferHeader* header = (video_decoder::RingBufferHeader*)shmem_base;
if (header->magic != video_decoder::RING_MAGIC || header->version != video_decoder::RING_VERSION) {
ALOGE("Invalid ring buffer magic/version");
return false;
}
if (!(header->flags & video_decoder::FLAG_DECODER_RUNNING)) {
return false;
}
uint32_t frame_size = header->frame_width * header->frame_height * 3 / 2;
uint32_t read_idx = header->write_index;
if (read_idx >= header->frame_count) {
ALOGE("Invalid write_index: %u", read_idx);
return false;
}
uint8_t* frame_data = shmem_base + video_decoder::RING_HEADER_SIZE + (read_idx * frame_size);
native_handle_t* handle = (native_handle_t*)buffer_handle;
if (!handle || handle->numFds < 1) {
ALOGE("Invalid native handle");
return false;
}
int fd = handle->data[0];
size_t buf_size = (size_t)width * height * 3 / 2;
void* addr = mmap(nullptr, buf_size, PROT_WRITE, MAP_SHARED, fd, 0);
if (addr == MAP_FAILED) {
ALOGE("mmap failed: %s", strerror(errno));
return false;
}
memcpy(addr, frame_data, frame_size);
munmap(addr, buf_size);
ALOGD("Injected frame %u (stream %d, %dx%d)", header->sequence, stream_id, width, height);
return true;
}
static void update_fps_counter() { static void update_fps_counter() {
auto now = std::chrono::steady_clock::now(); auto now = std::chrono::steady_clock::now();
+4
View File
@@ -644,6 +644,10 @@ int rtsp_client::get_rtsp_shmem_fd() {
return g_rtsp.shmem_fd; return g_rtsp.shmem_fd;
} }
uint8_t* rtsp_client::get_rtsp_shmem_base() {
return g_rtsp.shmem_base;
}
int rtsp_client::get_rtsp_width() { int rtsp_client::get_rtsp_width() {
return g_rtsp.width; return g_rtsp.width;
} }
+4
View File
@@ -387,6 +387,10 @@ int video_decoder::get_shmem_fd() {
return g_decoder.shmem_fd; return g_decoder.shmem_fd;
} }
uint8_t* video_decoder::get_shmem_base() {
return g_decoder.shmem_base;
}
int video_decoder::get_decoder_width() { int video_decoder::get_decoder_width() {
return g_decoder.width; return g_decoder.width;
} }
Binary file not shown.
Binary file not shown.
Binary file not shown.
+33
View File
@@ -0,0 +1,33 @@
#!/system/bin/sh
# CamSwapper VDM Privilege Module - Installation customization
# Sourced by KernelSU/Magisk installer after files are extracted.
SKIPUNZIP=0
# Set permissions
set_perm_recursive "$MODPATH/system" 0 0 0755 0644
set_perm "$MODPATH/system/priv-app/CamSwapper/CamSwapper.apk" 0 0 0644
set_perm "$MODPATH/system/etc/permissions/privapp-permissions-camswapper.xml" 0 0 0644
set_perm "$MODPATH/post-fs-data.sh" 0 0 0755
set_perm "$MODPATH/service.sh" 0 0 0755
set_perm "$MODPATH/uninstall.sh" 0 0 0755
# Remove user-installed version to avoid package conflict
if command -v pm >/dev/null 2>&1; then
pm uninstall com.nothing.camera2magic 2>/dev/null && \
ui_print "- Removed user-installed CamSwapper"
fi
ui_print ""
ui_print " CamSwapper VDM Privilege Module"
ui_print " ──────────────────────────────"
ui_print " Installed CamSwapper as privileged system app"
ui_print " Granted: REQUEST_COMPANION_PROFILE_APP_STREAMING"
ui_print " Granted: REQUEST_COMPANION_SELF_MANAGED"
ui_print " Granted: CREATE_VIRTUAL_DEVICE"
ui_print ""
ui_print " After reboot:"
ui_print " 1. Open CamSwapper (it's now a system app)"
ui_print " 2. Grant CAMERA + POST_NOTIFICATIONS permissions"
ui_print " 3. Start virtual camera"
ui_print ""
+6
View File
@@ -0,0 +1,6 @@
id=camswapper-vdm
name=CamSwapper VDM Privilege Module
version=3.2
versionCode=8
author=CamSwapper
description=Grants CamSwapper privileged system permissions via priv-app overlay, enabling VirtualDeviceManager (VDM) API access for system-level virtual camera creation. Compatible with KernelSU.
+28
View File
@@ -0,0 +1,28 @@
#!/system/bin/sh
# Set up overlay mount for CamSwapper priv-app
MODDIR=/data/adb/modules/camswapper-vdm
# Mount tmpfs for our overlay upperdir
mount -t tmpfs tmpfs /mnt/camswapper_overlay
# Create directory structure
mkdir -p /mnt/camswapper_overlay/upper/system/priv-app/CamSwapper
mkdir -p /mnt/camswapper_overlay/work
# Copy APK from module (it's already there from module packaging)
# No, we need to copy it - but the module file is readable by root
cp "$MODDIR/system/priv-app/CamSwapper/CamSwapper.apk" /mnt/camswapper_overlay/upper/system/priv-app/CamSwapper/
# Set proper permissions and SELinux context
chmod 755 /mnt/camswapper_overlay/upper/system/priv-app/CamSwapper
chmod 644 /mnt/camswapper_overlay/upper/system/priv-app/CamSwapper/CamSwapper.apk
chcon -R u:object_r:system_file:s0 /mnt/camswapper_overlay/upper/system/priv-app
# Mount overlay on /system/priv-app
mount -t overlay overlay \
-o lowerdir=/system/priv-app,upperdir=/mnt/camswapper_overlay/upper/system/priv-app,workdir=/mnt/camswapper_overlay/work \
/system/priv-app
echo "Overlay mount result: $?"
ls /system/priv-app/CamSwapper/ 2>&1
+39
View File
@@ -0,0 +1,39 @@
#!/system/bin/sh
MODDIR="${0%/*}"
OVERLAY_UPPER=/mnt/.cswp_overlay
OVERLAY_WORK=/mnt/.cswp_work
OVERLAY_WORK_ETC=/mnt/.cswp_work_etc
# Clean any stale workdir state from previous mounts. OverlayFS workdirs MUST be
# pristine — if left dirty after a lazy umount (umount -l), the kernel gets confused.
rm -rf "$OVERLAY_WORK" "$OVERLAY_WORK_ETC"
mkdir -p "$OVERLAY_WORK" "$OVERLAY_WORK_ETC"
mkdir -p "$OVERLAY_UPPER/system/priv-app/CamSwapper"
mkdir -p "$OVERLAY_UPPER/system/etc/permissions"
# Copy APK and privapp permissions XML to overlay upper dir.
# Android 16 requires privileged permission allowlisting — without the privapp XML,
# system_server FATAL EXCEPTION crashes on boot.
cp "$MODDIR/system/priv-app/CamSwapper/CamSwapper.apk" \
"$OVERLAY_UPPER/system/priv-app/CamSwapper/"
cp "$MODDIR/system/etc/permissions/privapp-permissions-camswapper.xml" \
"$OVERLAY_UPPER/system/etc/permissions/"
# Set permissions and SELinux context
chmod 755 "$OVERLAY_UPPER/system/priv-app/CamSwapper"
chmod 644 "$OVERLAY_UPPER/system/priv-app/CamSwapper/CamSwapper.apk"
chmod 644 "$OVERLAY_UPPER/system/etc/permissions/privapp-permissions-camswapper.xml"
chcon -R u:object_r:system_file:s0 "$OVERLAY_UPPER/system"
# Mount overlay on /system/priv-app so PackageManager sees CamSwapper as priv-app
mount -t overlay overlay \
-o lowerdir=/system/priv-app,upperdir="$OVERLAY_UPPER/system/priv-app",workdir="$OVERLAY_WORK" \
/system/priv-app
# Mount overlay on /system/etc/permissions with its own workdir so SystemConfig
# finds the privapp permissions XML during early boot.
mkdir -p "$OVERLAY_WORK_ETC"
mount -t overlay overlay \
-o lowerdir=/system/etc/permissions,upperdir="$OVERLAY_UPPER/system/etc/permissions",workdir="$OVERLAY_WORK_ETC" \
/system/etc/permissions
+4
View File
@@ -0,0 +1,4 @@
# Allow privileged app to use VirtualDeviceManager Binder service
allow priv_app virtualdevice_service:service_manager find;
allow priv_app virtualdevice_service:dir { search };
allow priv_app virtualdevice_service:file { read open };
+32
View File
@@ -0,0 +1,32 @@
#!/system/bin/sh
MODDIR="${0%/*}"
# Wait for boot to complete
while [ "$(getprop sys.boot_completed)" != 1 ]; do
/system/bin/sleep 1
done
# Clean up user-installed version if it's still around
INSTALL_PATH=$(pm path com.nothing.camera2magic 2>/dev/null | grep package: | head -1)
if echo "$INSTALL_PATH" | grep -q "/data/app/"; then
pm uninstall com.nothing.camera2magic 2>/dev/null
fi
# --- Diagnostics ---
LOG_TAG="CamSwapperModule"
log -t "$LOG_TAG" "=== CamSwapper VDM module service.sh ==="
# Verify overlay visibility
for f in \
/system/priv-app/CamSwapper/CamSwapper.apk \
/system/etc/permissions/privapp-permissions-camswapper.xml; do
if [ -f "$f" ]; then
log -t "$LOG_TAG" "OK: $f exists"
ls -la "$f" 2>&1 | log -t "$LOG_TAG"
else
log -t "$LOG_TAG" "MISSING: $f NOT FOUND"
fi
done
# Show mounts under /system
mount | grep " /system" | log -t "$LOG_TAG"
+2
View File
@@ -0,0 +1,2 @@
# CamSwapper VDM Module - system properties (optional)
# VirtualDeviceManager is enabled by default on Android 14+
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<permissions>
<!-- Grant privileged permissions to CamSwapper for VDM API access.
These permissions are signature|privileged level, requiring the app
to be installed as a privileged system app (in /system/priv-app/). -->
<privapp-permissions package="com.nothing.camera2magic">
<permission name="android.permission.REQUEST_COMPANION_PROFILE_APP_STREAMING"/>
<permission name="android.permission.REQUEST_COMPANION_SELF_MANAGED"/>
<permission name="android.permission.CREATE_VIRTUAL_DEVICE"/>
</privapp-permissions>
</permissions>
Binary file not shown.
+8
View File
@@ -0,0 +1,8 @@
#!/system/bin/sh
# CamSwapper VDM Privilege Module - Uninstall script
# Removes the privileged system app entry from package manager.
# Remove the system app entry from package manager
if command -v pm >/dev/null 2>&1; then
pm uninstall com.nothing.camera2magic 2>/dev/null
fi
+7
View File
@@ -0,0 +1,7 @@
LOCAL_PATH := $(call my-dir)
include $(CLEAR_VARS)
LOCAL_MODULE := camswapper
LOCAL_SRC_FILES := CamSwapperEntry.cpp
LOCAL_LDLIBS := -llog -ldl
include $(BUILD_SHARED_LIBRARY)
+4
View File
@@ -0,0 +1,4 @@
APP_ABI := arm64-v8a
APP_CPPFLAGS := -std=c++17 -fno-exceptions -fno-rtti -fvisibility=hidden -fvisibility-inlines-hidden
APP_STL := c++_static
APP_PLATFORM := android-21
+88
View File
@@ -0,0 +1,88 @@
#include <dlfcn.h>
#include <unistd.h>
#include <fcntl.h>
#include <string.h>
#include <sys/stat.h>
#include <android/log.h>
#include "zygisk.hpp"
#define LOG_TAG "CamSwapperZygisk"
#define ALOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
#define ALOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
#define HOOK_LIB_PATH "/data/local/camera_magic/libcamera_hook.so"
using zygisk::Api;
using zygisk::AppSpecializeArgs;
using zygisk::ServerSpecializeArgs;
static bool is_camera_process(const char *process_name) {
if (!process_name) return false;
return (strstr(process_name, "cameraserver") != nullptr) ||
(strstr(process_name, "camera.provider") != nullptr) ||
(strstr(process_name, "android.hardware.camera") != nullptr);
}
class CamSwapperModule : public zygisk::ModuleBase {
public:
void onLoad(Api *api, JNIEnv *env) override {
this->api = api;
this->env = env;
ALOGI("CamSwapper Zygisk module loaded");
}
void preAppSpecialize(AppSpecializeArgs *args) override {
if (args && args->nice_name) {
const char *process = env->GetStringUTFChars(args->nice_name, nullptr);
if (process) {
ALOGI("preAppSpecialize: %s", process);
env->ReleaseStringUTFChars(args->nice_name, process);
}
}
}
void postAppSpecialize(const AppSpecializeArgs *args) override {
if (args && args->nice_name) {
const char *process = env->GetStringUTFChars(args->nice_name, nullptr);
if (process) {
ALOGI("postAppSpecialize: %s", process);
if (is_camera_process(process)) {
ALOGI("Loading hook in camera app: %s", process);
load_hook_lib();
}
env->ReleaseStringUTFChars(args->nice_name, process);
}
}
}
void preServerSpecialize(ServerSpecializeArgs *args) override {
ALOGI("preServerSpecialize: system_server");
}
void postServerSpecialize(const ServerSpecializeArgs *args) override {
ALOGI("postServerSpecialize: system_server ready, loading hook");
load_hook_lib();
}
private:
Api *api;
JNIEnv *env;
void load_hook_lib() {
struct stat st;
if (stat(HOOK_LIB_PATH, &st) != 0) {
ALOGE("Hook library not found: %s", HOOK_LIB_PATH);
return;
}
void *handle = dlopen(HOOK_LIB_PATH, RTLD_NOW | RTLD_LOCAL);
if (!handle) {
ALOGE("dlopen(%s) failed: %s", HOOK_LIB_PATH, dlerror());
return;
}
ALOGI("Hook library loaded: %s (handle=%p)", HOOK_LIB_PATH, handle);
}
};
REGISTER_ZYGISK_MODULE(CamSwapperModule)
+391
View File
@@ -0,0 +1,391 @@
/* Copyright 2022-2023 John "topjohnwu" Wu
*
* Permission to use, copy, modify, and/or distribute this software for any
* purpose with or without fee is hereby granted.
*
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
* REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
* AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
* INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
* LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
* OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
* PERFORMANCE OF THIS SOFTWARE.
*/
// This is the public API for Zygisk modules.
// DO NOT MODIFY ANY CODE IN THIS HEADER.
#pragma once
#include <jni.h>
#define ZYGISK_API_VERSION 4
/*
***************
* Introduction
***************
On Android, all app processes are forked from a special daemon called "Zygote".
For each new app process, zygote will fork a new process and perform "specialization".
This specialization operation enforces the Android security sandbox on the newly forked
process to make sure that 3rd party application code is only loaded after it is being
restricted within a sandbox.
On Android, there is also this special process called "system_server". This single
process hosts a significant portion of system services, which controls how the
Android operating system and apps interact with each other.
The Zygisk framework provides a way to allow developers to build modules and run custom
code before and after system_server and any app processes' specialization.
This enable developers to inject code and alter the behavior of system_server and app processes.
Please note that modules will only be loaded after zygote has forked the child process.
THIS MEANS ALL OF YOUR CODE RUNS IN THE APP/SYSTEM_SERVER PROCESS, NOT THE ZYGOTE DAEMON!
*********************
* Development Guide
*********************
Define a class and inherit zygisk::ModuleBase to implement the functionality of your module.
Use the macro REGISTER_ZYGISK_MODULE(className) to register that class to Zygisk.
Example code:
static jint (*orig_logger_entry_max)(JNIEnv *env);
static jint my_logger_entry_max(JNIEnv *env) { return orig_logger_entry_max(env); }
class ExampleModule : public zygisk::ModuleBase {
public:
void onLoad(zygisk::Api *api, JNIEnv *env) override {
this->api = api;
this->env = env;
}
void preAppSpecialize(zygisk::AppSpecializeArgs *args) override {
JNINativeMethod methods[] = {
{ "logger_entry_max_payload_native", "()I", (void*) my_logger_entry_max },
};
api->hookJniNativeMethods(env, "android/util/Log", methods, 1);
*(void **) &orig_logger_entry_max = methods[0].fnPtr;
}
private:
zygisk::Api *api;
JNIEnv *env;
};
REGISTER_ZYGISK_MODULE(ExampleModule)
-----------------------------------------------------------------------------------------
Since your module class's code runs with either Zygote's privilege in pre[XXX]Specialize,
or runs in the sandbox of the target process in post[XXX]Specialize, the code in your class
never runs in a true superuser environment.
If your module require access to superuser permissions, you can create and register
a root companion handler function. This function runs in a separate root companion
daemon process, and an Unix domain socket is provided to allow you to perform IPC between
your target process and the root companion process.
Example code:
static void example_handler(int socket) { ... }
REGISTER_ZYGISK_COMPANION(example_handler)
*/
namespace zygisk {
struct Api;
struct AppSpecializeArgs;
struct ServerSpecializeArgs;
class ModuleBase {
public:
// This method is called as soon as the module is loaded into the target process.
// A Zygisk API handle will be passed as an argument.
virtual void onLoad([[maybe_unused]] Api *api, [[maybe_unused]] JNIEnv *env) {}
// This method is called before the app process is specialized.
// At this point, the process just got forked from zygote, but no app specific specialization
// is applied. This means that the process does not have any sandbox restrictions and
// still runs with the same privilege of zygote.
//
// All the arguments that will be sent and used for app specialization is passed as a single
// AppSpecializeArgs object. You can read and overwrite these arguments to change how the app
// process will be specialized.
//
// If you need to run some operations as superuser, you can call Api::connectCompanion() to
// get a socket to do IPC calls with a root companion process.
// See Api::connectCompanion() for more info.
virtual void preAppSpecialize([[maybe_unused]] AppSpecializeArgs *args) {}
// This method is called after the app process is specialized.
// At this point, the process has all sandbox restrictions enabled for this application.
// This means that this method runs with the same privilege of the app's own code.
virtual void postAppSpecialize([[maybe_unused]] const AppSpecializeArgs *args) {}
// This method is called before the system server process is specialized.
// See preAppSpecialize(args) for more info.
virtual void preServerSpecialize([[maybe_unused]] ServerSpecializeArgs *args) {}
// This method is called after the system server process is specialized.
// At this point, the process runs with the privilege of system_server.
virtual void postServerSpecialize([[maybe_unused]] const ServerSpecializeArgs *args) {}
};
struct AppSpecializeArgs {
// Required arguments. These arguments are guaranteed to exist on all Android versions.
jint &uid;
jint &gid;
jintArray &gids;
jint &runtime_flags;
jobjectArray &rlimits;
jint &mount_external;
jstring &se_info;
jstring &nice_name;
jstring &instruction_set;
jstring &app_data_dir;
// Optional arguments. Please check whether the pointer is null before de-referencing
jintArray *const fds_to_ignore;
jboolean *const is_child_zygote;
jboolean *const is_top_app;
jobjectArray *const pkg_data_info_list;
jobjectArray *const whitelisted_data_info_list;
jboolean *const mount_data_dirs;
jboolean *const mount_storage_dirs;
AppSpecializeArgs() = delete;
};
struct ServerSpecializeArgs {
jint &uid;
jint &gid;
jintArray &gids;
jint &runtime_flags;
jlong &permitted_capabilities;
jlong &effective_capabilities;
ServerSpecializeArgs() = delete;
};
namespace internal {
struct api_table;
template <class T> void entry_impl(api_table *, JNIEnv *);
}
// These values are used in Api::setOption(Option)
enum Option : int {
// Force Magisk's denylist unmount routines to run on this process.
//
// Setting this option only makes sense in preAppSpecialize.
// The actual unmounting happens during app process specialization.
//
// Set this option to force all Magisk and modules' files to be unmounted from the
// mount namespace of the process, regardless of the denylist enforcement status.
FORCE_DENYLIST_UNMOUNT = 0,
// When this option is set, your module's library will be dlclose-ed after post[XXX]Specialize.
// Be aware that after dlclose-ing your module, all of your code will be unmapped from memory.
// YOU MUST NOT ENABLE THIS OPTION AFTER HOOKING ANY FUNCTIONS IN THE PROCESS.
DLCLOSE_MODULE_LIBRARY = 1,
};
// Bit masks of the return value of Api::getFlags()
enum StateFlag : uint32_t {
// The user has granted root access to the current process
PROCESS_GRANTED_ROOT = (1u << 0),
// The current process was added on the denylist
PROCESS_ON_DENYLIST = (1u << 1),
};
// All API methods will stop working after post[XXX]Specialize as Zygisk will be unloaded
// from the specialized process afterwards.
struct Api {
// Connect to a root companion process and get a Unix domain socket for IPC.
//
// This API only works in the pre[XXX]Specialize methods due to SELinux restrictions.
//
// The pre[XXX]Specialize methods run with the same privilege of zygote.
// If you would like to do some operations with superuser permissions, register a handler
// function that would be called in the root process with REGISTER_ZYGISK_COMPANION(func).
// Another good use case for a companion process is that if you want to share some resources
// across multiple processes, hold the resources in the companion process and pass it over.
//
// The root companion process is ABI aware; that is, when calling this method from a 32-bit
// process, you will be connected to a 32-bit companion process, and vice versa for 64-bit.
//
// Returns a file descriptor to a socket that is connected to the socket passed to your
// module's companion request handler. Returns -1 if the connection attempt failed.
int connectCompanion();
// Get the file descriptor of the root folder of the current module.
//
// This API only works in the pre[XXX]Specialize methods.
// Accessing the directory returned is only possible in the pre[XXX]Specialize methods
// or in the root companion process (assuming that you sent the fd over the socket).
// Both restrictions are due to SELinux and UID.
//
// Returns -1 if errors occurred.
int getModuleDir();
// Set various options for your module.
// Please note that this method accepts one single option at a time.
// Check zygisk::Option for the full list of options available.
void setOption(Option opt);
// Get information about the current process.
// Returns bitwise-or'd zygisk::StateFlag values.
uint32_t getFlags();
// Exempt the provided file descriptor from being automatically closed.
//
// This API only make sense in preAppSpecialize; calling this method in any other situation
// is either a no-op (returns true) or an error (returns false).
//
// When false is returned, the provided file descriptor will eventually be closed by zygote.
bool exemptFd(int fd);
// Hook JNI native methods for a class
//
// Lookup all registered JNI native methods and replace it with your own methods.
// The original function pointer will be saved in each JNINativeMethod's fnPtr.
// If no matching class, method name, or signature is found, that specific JNINativeMethod.fnPtr
// will be set to nullptr.
void hookJniNativeMethods(JNIEnv *env, const char *className, JNINativeMethod *methods, int numMethods);
// Hook functions in the PLT (Procedure Linkage Table) of ELFs loaded in memory.
//
// Parsing /proc/[PID]/maps will give you the memory map of a process. As an example:
//
// <address> <perms> <offset> <dev> <inode> <pathname>
// 56b4346000-56b4347000 r-xp 00002000 fe:00 235 /system/bin/app_process64
// (More details: https://man7.org/linux/man-pages/man5/proc.5.html)
//
// The `dev` and `inode` pair uniquely identifies a file being mapped into memory.
// For matching ELFs loaded in memory, replace function `symbol` with `newFunc`.
// If `oldFunc` is not nullptr, the original function pointer will be saved to `oldFunc`.
void pltHookRegister(dev_t dev, ino_t inode, const char *symbol, void *newFunc, void **oldFunc);
// Commit all the hooks that was previously registered.
// Returns false if an error occurred.
bool pltHookCommit();
private:
internal::api_table *tbl;
template <class T> friend void internal::entry_impl(internal::api_table *, JNIEnv *);
};
// Register a class as a Zygisk module
#define REGISTER_ZYGISK_MODULE(clazz) \
void zygisk_module_entry(zygisk::internal::api_table *table, JNIEnv *env) { \
zygisk::internal::entry_impl<clazz>(table, env); \
}
// Register a root companion request handler function for your module
//
// The function runs in a superuser daemon process and handles a root companion request from
// your module running in a target process. The function has to accept an integer value,
// which is a Unix domain socket that is connected to the target process.
// See Api::connectCompanion() for more info.
//
// NOTE: the function can run concurrently on multiple threads.
// Be aware of race conditions if you have globally shared resources.
#define REGISTER_ZYGISK_COMPANION(func) \
void zygisk_companion_entry(int client) { func(client); }
/*********************************************************
* The following is internal ABI implementation detail.
* You do not have to understand what it is doing.
*********************************************************/
namespace internal {
struct module_abi {
long api_version;
ModuleBase *impl;
void (*preAppSpecialize)(ModuleBase *, AppSpecializeArgs *);
void (*postAppSpecialize)(ModuleBase *, const AppSpecializeArgs *);
void (*preServerSpecialize)(ModuleBase *, ServerSpecializeArgs *);
void (*postServerSpecialize)(ModuleBase *, const ServerSpecializeArgs *);
module_abi(ModuleBase *module) : api_version(ZYGISK_API_VERSION), impl(module) {
preAppSpecialize = [](auto m, auto args) { m->preAppSpecialize(args); };
postAppSpecialize = [](auto m, auto args) { m->postAppSpecialize(args); };
preServerSpecialize = [](auto m, auto args) { m->preServerSpecialize(args); };
postServerSpecialize = [](auto m, auto args) { m->postServerSpecialize(args); };
}
};
struct api_table {
// Base
void *impl;
bool (*registerModule)(api_table *, module_abi *);
void (*hookJniNativeMethods)(JNIEnv *, const char *, JNINativeMethod *, int);
void (*pltHookRegister)(dev_t, ino_t, const char *, void *, void **);
bool (*exemptFd)(int);
bool (*pltHookCommit)();
int (*connectCompanion)(void * /* impl */);
void (*setOption)(void * /* impl */, Option);
int (*getModuleDir)(void * /* impl */);
uint32_t (*getFlags)(void * /* impl */);
};
template <class T>
void entry_impl(api_table *table, JNIEnv *env) {
static Api api;
api.tbl = table;
static T module;
ModuleBase *m = &module;
static module_abi abi(m);
if (!table->registerModule(table, &abi)) return;
m->onLoad(&api, env);
}
} // namespace internal
inline int Api::connectCompanion() {
return tbl->connectCompanion ? tbl->connectCompanion(tbl->impl) : -1;
}
inline int Api::getModuleDir() {
return tbl->getModuleDir ? tbl->getModuleDir(tbl->impl) : -1;
}
inline void Api::setOption(Option opt) {
if (tbl->setOption) tbl->setOption(tbl->impl, opt);
}
inline uint32_t Api::getFlags() {
return tbl->getFlags ? tbl->getFlags(tbl->impl) : 0;
}
inline bool Api::exemptFd(int fd) {
return tbl->exemptFd != nullptr && tbl->exemptFd(fd);
}
inline void Api::hookJniNativeMethods(JNIEnv *env, const char *className, JNINativeMethod *methods, int numMethods) {
if (tbl->hookJniNativeMethods) tbl->hookJniNativeMethods(env, className, methods, numMethods);
}
inline void Api::pltHookRegister(dev_t dev, ino_t inode, const char *symbol, void *newFunc, void **oldFunc) {
if (tbl->pltHookRegister) tbl->pltHookRegister(dev, inode, symbol, newFunc, oldFunc);
}
inline bool Api::pltHookCommit() {
return tbl->pltHookCommit != nullptr && tbl->pltHookCommit();
}
} // namespace zygisk
extern "C" {
[[gnu::visibility("default"), maybe_unused]]
void zygisk_module_entry(zygisk::internal::api_table *, JNIEnv *);
[[gnu::visibility("default"), maybe_unused]]
void zygisk_companion_entry(int);
} // extern "C"
Binary file not shown.
+3 -3
View File
@@ -1,6 +1,6 @@
id=camera-hook-zygisk id=camera-hook-zygisk
name=CamSwapper Zygisk Hook name=CamSwapper Zygisk Hook
version=1.0 version=2.0
versionCode=1 versionCode=2
author=CamSwapper author=CamSwapper
description=Injects libcamera_hook.so into cameraserver via Zygisk description=Injects libcamera_hook.so into cameraserver via Zygisk (v2.0 - proper API)
Binary file not shown.
@@ -0,0 +1,11 @@
./obj/local/arm64-v8a/objs/camswapper/CamSwapperEntry.o: \
jni/CamSwapperEntry.cpp \
/tmp/android-ndk-r25c/sources/cxx-stl/llvm-libc++/include/string.h \
/tmp/android-ndk-r25c/sources/cxx-stl/llvm-libc++/include/__config \
jni/zygisk.hpp
/tmp/android-ndk-r25c/sources/cxx-stl/llvm-libc++/include/string.h:
/tmp/android-ndk-r25c/sources/cxx-stl/llvm-libc++/include/__config:
jni/zygisk.hpp:
+25
View File
@@ -0,0 +1,25 @@
#!/system/bin/sh
MODDIR="${0%/*}"
MODPROP="$MODDIR/module.prop"
update_description() {
if [ -f "$MODPROP" ]; then
sed -i "s|^description=.*|description=$1|" "$MODPROP"
fi
}
update_description "Checking Zygisk module status..."
# Check if Zygisk framework is available
if [ ! -d "/data/adb/modules" ]; then
update_description "Error - /data/adb/modules not found"
exit 1
fi
# Check if our module is loaded (check for libcamera_hook.so in cameraserver)
CS_PID=$(pidof cameraserver 2>/dev/null)
if [ -n "$CS_PID" ] && grep -q "libcamera_hook.so" /proc/"$CS_PID"/maps 2>/dev/null; then
update_description "Active - hook loaded in cameraserver PID $CS_PID"
else
update_description "Zygisk module loaded - waiting for cameraserver"
fi
+391
View File
@@ -0,0 +1,391 @@
/* Copyright 2022-2023 John "topjohnwu" Wu
*
* Permission to use, copy, modify, and/or distribute this software for any
* purpose with or without fee is hereby granted.
*
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
* REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
* AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
* INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
* LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
* OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
* PERFORMANCE OF THIS SOFTWARE.
*/
// This is the public API for Zygisk modules.
// DO NOT MODIFY ANY CODE IN THIS HEADER.
#pragma once
#include <jni.h>
#define ZYGISK_API_VERSION 4
/*
***************
* Introduction
***************
On Android, all app processes are forked from a special daemon called "Zygote".
For each new app process, zygote will fork a new process and perform "specialization".
This specialization operation enforces the Android security sandbox on the newly forked
process to make sure that 3rd party application code is only loaded after it is being
restricted within a sandbox.
On Android, there is also this special process called "system_server". This single
process hosts a significant portion of system services, which controls how the
Android operating system and apps interact with each other.
The Zygisk framework provides a way to allow developers to build modules and run custom
code before and after system_server and any app processes' specialization.
This enable developers to inject code and alter the behavior of system_server and app processes.
Please note that modules will only be loaded after zygote has forked the child process.
THIS MEANS ALL OF YOUR CODE RUNS IN THE APP/SYSTEM_SERVER PROCESS, NOT THE ZYGOTE DAEMON!
*********************
* Development Guide
*********************
Define a class and inherit zygisk::ModuleBase to implement the functionality of your module.
Use the macro REGISTER_ZYGISK_MODULE(className) to register that class to Zygisk.
Example code:
static jint (*orig_logger_entry_max)(JNIEnv *env);
static jint my_logger_entry_max(JNIEnv *env) { return orig_logger_entry_max(env); }
class ExampleModule : public zygisk::ModuleBase {
public:
void onLoad(zygisk::Api *api, JNIEnv *env) override {
this->api = api;
this->env = env;
}
void preAppSpecialize(zygisk::AppSpecializeArgs *args) override {
JNINativeMethod methods[] = {
{ "logger_entry_max_payload_native", "()I", (void*) my_logger_entry_max },
};
api->hookJniNativeMethods(env, "android/util/Log", methods, 1);
*(void **) &orig_logger_entry_max = methods[0].fnPtr;
}
private:
zygisk::Api *api;
JNIEnv *env;
};
REGISTER_ZYGISK_MODULE(ExampleModule)
-----------------------------------------------------------------------------------------
Since your module class's code runs with either Zygote's privilege in pre[XXX]Specialize,
or runs in the sandbox of the target process in post[XXX]Specialize, the code in your class
never runs in a true superuser environment.
If your module require access to superuser permissions, you can create and register
a root companion handler function. This function runs in a separate root companion
daemon process, and an Unix domain socket is provided to allow you to perform IPC between
your target process and the root companion process.
Example code:
static void example_handler(int socket) { ... }
REGISTER_ZYGISK_COMPANION(example_handler)
*/
namespace zygisk {
struct Api;
struct AppSpecializeArgs;
struct ServerSpecializeArgs;
class ModuleBase {
public:
// This method is called as soon as the module is loaded into the target process.
// A Zygisk API handle will be passed as an argument.
virtual void onLoad([[maybe_unused]] Api *api, [[maybe_unused]] JNIEnv *env) {}
// This method is called before the app process is specialized.
// At this point, the process just got forked from zygote, but no app specific specialization
// is applied. This means that the process does not have any sandbox restrictions and
// still runs with the same privilege of zygote.
//
// All the arguments that will be sent and used for app specialization is passed as a single
// AppSpecializeArgs object. You can read and overwrite these arguments to change how the app
// process will be specialized.
//
// If you need to run some operations as superuser, you can call Api::connectCompanion() to
// get a socket to do IPC calls with a root companion process.
// See Api::connectCompanion() for more info.
virtual void preAppSpecialize([[maybe_unused]] AppSpecializeArgs *args) {}
// This method is called after the app process is specialized.
// At this point, the process has all sandbox restrictions enabled for this application.
// This means that this method runs with the same privilege of the app's own code.
virtual void postAppSpecialize([[maybe_unused]] const AppSpecializeArgs *args) {}
// This method is called before the system server process is specialized.
// See preAppSpecialize(args) for more info.
virtual void preServerSpecialize([[maybe_unused]] ServerSpecializeArgs *args) {}
// This method is called after the system server process is specialized.
// At this point, the process runs with the privilege of system_server.
virtual void postServerSpecialize([[maybe_unused]] const ServerSpecializeArgs *args) {}
};
struct AppSpecializeArgs {
// Required arguments. These arguments are guaranteed to exist on all Android versions.
jint &uid;
jint &gid;
jintArray &gids;
jint &runtime_flags;
jobjectArray &rlimits;
jint &mount_external;
jstring &se_info;
jstring &nice_name;
jstring &instruction_set;
jstring &app_data_dir;
// Optional arguments. Please check whether the pointer is null before de-referencing
jintArray *const fds_to_ignore;
jboolean *const is_child_zygote;
jboolean *const is_top_app;
jobjectArray *const pkg_data_info_list;
jobjectArray *const whitelisted_data_info_list;
jboolean *const mount_data_dirs;
jboolean *const mount_storage_dirs;
AppSpecializeArgs() = delete;
};
struct ServerSpecializeArgs {
jint &uid;
jint &gid;
jintArray &gids;
jint &runtime_flags;
jlong &permitted_capabilities;
jlong &effective_capabilities;
ServerSpecializeArgs() = delete;
};
namespace internal {
struct api_table;
template <class T> void entry_impl(api_table *, JNIEnv *);
}
// These values are used in Api::setOption(Option)
enum Option : int {
// Force Magisk's denylist unmount routines to run on this process.
//
// Setting this option only makes sense in preAppSpecialize.
// The actual unmounting happens during app process specialization.
//
// Set this option to force all Magisk and modules' files to be unmounted from the
// mount namespace of the process, regardless of the denylist enforcement status.
FORCE_DENYLIST_UNMOUNT = 0,
// When this option is set, your module's library will be dlclose-ed after post[XXX]Specialize.
// Be aware that after dlclose-ing your module, all of your code will be unmapped from memory.
// YOU MUST NOT ENABLE THIS OPTION AFTER HOOKING ANY FUNCTIONS IN THE PROCESS.
DLCLOSE_MODULE_LIBRARY = 1,
};
// Bit masks of the return value of Api::getFlags()
enum StateFlag : uint32_t {
// The user has granted root access to the current process
PROCESS_GRANTED_ROOT = (1u << 0),
// The current process was added on the denylist
PROCESS_ON_DENYLIST = (1u << 1),
};
// All API methods will stop working after post[XXX]Specialize as Zygisk will be unloaded
// from the specialized process afterwards.
struct Api {
// Connect to a root companion process and get a Unix domain socket for IPC.
//
// This API only works in the pre[XXX]Specialize methods due to SELinux restrictions.
//
// The pre[XXX]Specialize methods run with the same privilege of zygote.
// If you would like to do some operations with superuser permissions, register a handler
// function that would be called in the root process with REGISTER_ZYGISK_COMPANION(func).
// Another good use case for a companion process is that if you want to share some resources
// across multiple processes, hold the resources in the companion process and pass it over.
//
// The root companion process is ABI aware; that is, when calling this method from a 32-bit
// process, you will be connected to a 32-bit companion process, and vice versa for 64-bit.
//
// Returns a file descriptor to a socket that is connected to the socket passed to your
// module's companion request handler. Returns -1 if the connection attempt failed.
int connectCompanion();
// Get the file descriptor of the root folder of the current module.
//
// This API only works in the pre[XXX]Specialize methods.
// Accessing the directory returned is only possible in the pre[XXX]Specialize methods
// or in the root companion process (assuming that you sent the fd over the socket).
// Both restrictions are due to SELinux and UID.
//
// Returns -1 if errors occurred.
int getModuleDir();
// Set various options for your module.
// Please note that this method accepts one single option at a time.
// Check zygisk::Option for the full list of options available.
void setOption(Option opt);
// Get information about the current process.
// Returns bitwise-or'd zygisk::StateFlag values.
uint32_t getFlags();
// Exempt the provided file descriptor from being automatically closed.
//
// This API only make sense in preAppSpecialize; calling this method in any other situation
// is either a no-op (returns true) or an error (returns false).
//
// When false is returned, the provided file descriptor will eventually be closed by zygote.
bool exemptFd(int fd);
// Hook JNI native methods for a class
//
// Lookup all registered JNI native methods and replace it with your own methods.
// The original function pointer will be saved in each JNINativeMethod's fnPtr.
// If no matching class, method name, or signature is found, that specific JNINativeMethod.fnPtr
// will be set to nullptr.
void hookJniNativeMethods(JNIEnv *env, const char *className, JNINativeMethod *methods, int numMethods);
// Hook functions in the PLT (Procedure Linkage Table) of ELFs loaded in memory.
//
// Parsing /proc/[PID]/maps will give you the memory map of a process. As an example:
//
// <address> <perms> <offset> <dev> <inode> <pathname>
// 56b4346000-56b4347000 r-xp 00002000 fe:00 235 /system/bin/app_process64
// (More details: https://man7.org/linux/man-pages/man5/proc.5.html)
//
// The `dev` and `inode` pair uniquely identifies a file being mapped into memory.
// For matching ELFs loaded in memory, replace function `symbol` with `newFunc`.
// If `oldFunc` is not nullptr, the original function pointer will be saved to `oldFunc`.
void pltHookRegister(dev_t dev, ino_t inode, const char *symbol, void *newFunc, void **oldFunc);
// Commit all the hooks that was previously registered.
// Returns false if an error occurred.
bool pltHookCommit();
private:
internal::api_table *tbl;
template <class T> friend void internal::entry_impl(internal::api_table *, JNIEnv *);
};
// Register a class as a Zygisk module
#define REGISTER_ZYGISK_MODULE(clazz) \
void zygisk_module_entry(zygisk::internal::api_table *table, JNIEnv *env) { \
zygisk::internal::entry_impl<clazz>(table, env); \
}
// Register a root companion request handler function for your module
//
// The function runs in a superuser daemon process and handles a root companion request from
// your module running in a target process. The function has to accept an integer value,
// which is a Unix domain socket that is connected to the target process.
// See Api::connectCompanion() for more info.
//
// NOTE: the function can run concurrently on multiple threads.
// Be aware of race conditions if you have globally shared resources.
#define REGISTER_ZYGISK_COMPANION(func) \
void zygisk_companion_entry(int client) { func(client); }
/*********************************************************
* The following is internal ABI implementation detail.
* You do not have to understand what it is doing.
*********************************************************/
namespace internal {
struct module_abi {
long api_version;
ModuleBase *impl;
void (*preAppSpecialize)(ModuleBase *, AppSpecializeArgs *);
void (*postAppSpecialize)(ModuleBase *, const AppSpecializeArgs *);
void (*preServerSpecialize)(ModuleBase *, ServerSpecializeArgs *);
void (*postServerSpecialize)(ModuleBase *, const ServerSpecializeArgs *);
module_abi(ModuleBase *module) : api_version(ZYGISK_API_VERSION), impl(module) {
preAppSpecialize = [](auto m, auto args) { m->preAppSpecialize(args); };
postAppSpecialize = [](auto m, auto args) { m->postAppSpecialize(args); };
preServerSpecialize = [](auto m, auto args) { m->preServerSpecialize(args); };
postServerSpecialize = [](auto m, auto args) { m->postServerSpecialize(args); };
}
};
struct api_table {
// Base
void *impl;
bool (*registerModule)(api_table *, module_abi *);
void (*hookJniNativeMethods)(JNIEnv *, const char *, JNINativeMethod *, int);
void (*pltHookRegister)(dev_t, ino_t, const char *, void *, void **);
bool (*exemptFd)(int);
bool (*pltHookCommit)();
int (*connectCompanion)(void * /* impl */);
void (*setOption)(void * /* impl */, Option);
int (*getModuleDir)(void * /* impl */);
uint32_t (*getFlags)(void * /* impl */);
};
template <class T>
void entry_impl(api_table *table, JNIEnv *env) {
static Api api;
api.tbl = table;
static T module;
ModuleBase *m = &module;
static module_abi abi(m);
if (!table->registerModule(table, &abi)) return;
m->onLoad(&api, env);
}
} // namespace internal
inline int Api::connectCompanion() {
return tbl->connectCompanion ? tbl->connectCompanion(tbl->impl) : -1;
}
inline int Api::getModuleDir() {
return tbl->getModuleDir ? tbl->getModuleDir(tbl->impl) : -1;
}
inline void Api::setOption(Option opt) {
if (tbl->setOption) tbl->setOption(tbl->impl, opt);
}
inline uint32_t Api::getFlags() {
return tbl->getFlags ? tbl->getFlags(tbl->impl) : 0;
}
inline bool Api::exemptFd(int fd) {
return tbl->exemptFd != nullptr && tbl->exemptFd(fd);
}
inline void Api::hookJniNativeMethods(JNIEnv *env, const char *className, JNINativeMethod *methods, int numMethods) {
if (tbl->hookJniNativeMethods) tbl->hookJniNativeMethods(env, className, methods, numMethods);
}
inline void Api::pltHookRegister(dev_t dev, ino_t inode, const char *symbol, void *newFunc, void **oldFunc) {
if (tbl->pltHookRegister) tbl->pltHookRegister(dev, inode, symbol, newFunc, oldFunc);
}
inline bool Api::pltHookCommit() {
return tbl->pltHookCommit != nullptr && tbl->pltHookCommit();
}
} // namespace zygisk
extern "C" {
[[gnu::visibility("default"), maybe_unused]]
void zygisk_module_entry(zygisk::internal::api_table *, JNIEnv *);
[[gnu::visibility("default"), maybe_unused]]
void zygisk_companion_entry(int);
} // extern "C"
Binary file not shown.
+64 -89
View File
@@ -1,113 +1,88 @@
#include <cstdlib>
#include <cstring>
#include <cstdio>
#include <dlfcn.h> #include <dlfcn.h>
#include <unistd.h> #include <unistd.h>
#include <fcntl.h> #include <fcntl.h>
#include <pthread.h> #include <cstring>
#include <cstdio>
#include <android/log.h> #include <android/log.h>
#include <sys/stat.h> #include "zygisk.hpp"
#include <jni.h>
#define LOG_TAG "CameraHookZygisk" #define LOG_TAG "CamSwapperZygisk"
#define ALOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__) #define ALOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
#define ALOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) #define ALOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
#define HOOK_LIB_PATH "/data/local/camera_magic/libcamera_hook.so" #define HOOK_LIB_PATH "/data/local/camera_magic/libcamera_hook.so"
static void* g_hook_handle = nullptr; using zygisk::Api;
static bool g_hook_loaded = false; using zygisk::AppSpecializeArgs;
static bool g_in_zygote = false; using zygisk::ServerSpecializeArgs;
static bool is_camera_process() { static bool is_camera_process(const char *process_name) {
char cmdline[256] = {0}; if (!process_name) return false;
int fd = open("/proc/self/cmdline", O_RDONLY); return (strstr(process_name, "cameraserver") != nullptr) ||
if (fd < 0) return false; (strstr(process_name, "camera.provider") != nullptr) ||
ssize_t n = read(fd, cmdline, sizeof(cmdline) - 1); (strstr(process_name, "android.hardware.camera") != nullptr);
close(fd);
if (n <= 0) return false;
return (strstr(cmdline, "cameraserver") != nullptr ||
strstr(cmdline, "camera.provider") != nullptr ||
strstr(cmdline, "android.hardware.camera") != nullptr);
} }
static void load_hook() { class CamSwapperModule : public zygisk::ModuleBase {
if (g_hook_loaded) return; public:
g_hook_loaded = true; void onLoad(Api *api, JNIEnv *env) override {
this->api = api;
this->env = env;
ALOGI("CamSwapper Zygisk module loaded");
}
void preAppSpecialize(AppSpecializeArgs *args) override {
if (args && args->nice_name) {
const char *process = env->GetStringUTFChars(args->nice_name, nullptr);
if (process) {
ALOGI("preAppSpecialize: %s", process);
env->ReleaseStringUTFChars(args->nice_name, process);
}
}
}
void postAppSpecialize(const AppSpecializeArgs *args) override {
if (args && args->nice_name) {
const char *process = env->GetStringUTFChars(args->nice_name, nullptr);
if (process) {
ALOGI("postAppSpecialize: %s", process);
if (is_camera_process(process)) {
ALOGI("Loading hook in camera app: %s", process);
load_hook_lib();
}
env->ReleaseStringUTFChars(args->nice_name, process);
}
}
}
void preServerSpecialize(ServerSpecializeArgs *args) override {
ALOGI("preServerSpecialize: system_server");
}
void postServerSpecialize(const ServerSpecializeArgs *args) override {
ALOGI("postServerSpecialize: system_server ready, loading hook");
load_hook_lib();
}
private:
Api *api;
JNIEnv *env;
void load_hook_lib() {
struct stat st; struct stat st;
if (stat(HOOK_LIB_PATH, &st) != 0) { if (stat(HOOK_LIB_PATH, &st) != 0) {
ALOGE("Hook library not found: %s", HOOK_LIB_PATH); ALOGE("Hook library not found: %s", HOOK_LIB_PATH);
return; return;
} }
g_hook_handle = dlopen(HOOK_LIB_PATH, RTLD_NOW | RTLD_LOCAL);
if (!g_hook_handle) { void *handle = dlopen(HOOK_LIB_PATH, RTLD_NOW | RTLD_LOCAL);
if (!handle) {
ALOGE("dlopen(%s) failed: %s", HOOK_LIB_PATH, dlerror()); ALOGE("dlopen(%s) failed: %s", HOOK_LIB_PATH, dlerror());
return; return;
} }
ALOGI("Hook library loaded in PID %d", getpid());
}
static void post_fork_child() { ALOGI("Hook library loaded: %s (handle=%p)", HOOK_LIB_PATH, handle);
if (is_camera_process()) {
ALOGI("Camera process detected (PID %d), loading hook", getpid());
load_hook();
}
} }
};
__attribute__((constructor)) REGISTER_ZYGISK_MODULE(CamSwapperModule)
static void zygisk_init() {
char cmdline[256] = {0};
int fd = open("/proc/self/cmdline", O_RDONLY);
if (fd >= 0) {
read(fd, cmdline, sizeof(cmdline) - 1);
close(fd);
}
if (strstr(cmdline, "zygote") || strstr(cmdline, "zygote64")) {
g_in_zygote = true;
ALOGI("Loaded in Zygote (PID %d), registering atfork", getpid());
pthread_atfork(nullptr, nullptr, post_fork_child);
} else if (is_camera_process()) {
ALOGI("Loaded directly in camera process (PID %d)", getpid());
load_hook();
}
}
extern "C" {
__attribute__((visibility("default")))
void zygisk_module_entry(void* api, JNIEnv* env) {
(void)api;
(void)env;
}
__attribute__((visibility("default")))
void pre_app_specialize(void* module, void* args) {
(void)module;
(void)args;
}
__attribute__((visibility("default")))
void post_app_specialize(void* module, const void* args) {
(void)module;
(void)args;
if (is_camera_process()) {
ALOGI("Camera app detected via post_app_specialize (PID %d)", getpid());
load_hook();
}
}
__attribute__((visibility("default")))
void pre_server_specialize(void* module, void* args) {
(void)module;
(void)args;
}
__attribute__((visibility("default")))
void post_server_specialize(void* module, const void* args) {
(void)module;
(void)args;
ALOGI("Server specialized (PID %d), loading hook", getpid());
load_hook();
}
}