Initial commit

This commit is contained in:
2026-05-08 11:45:15 +02:00
commit e5471b5fa0
120 changed files with 8938 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
.gradle/
.idea/
.kotlin/
build/
**/.cache
app/build/
app/release/
local.properties
*.iml
**/.cxx
.clang-format
!/app/src/main/jniLib
.DS_Store
/app/src/main/cpp
app/modern
app/legacy
.sisyphus/
+103
View File
@@ -0,0 +1,103 @@
# CamSwapper: A Virtual Camera Module (Android 10+)
**PLEASE DO NOT USE THIS SOFTWARE FOR ILLEGAL PURPOSES.**
CamSwapper is a virtual camera module that replaces your device's camera feed with video files or images at the application level (via Xposed/LSPosed) or system-wide (via HAL hook).
## Quick Start
1. Install the CamSwapper APK on a rooted device with LSPosed
2. Enable module in LSPosed Manager and select target apps
3. Select a video file from the app UI
4. Open any hooked camera app — your video plays as the camera feed
## Features
### App-Level Hook (LSPosed)
| Feature | Status |
|---------|-------|
| Local video file hooking | [x] |
| AMediaCodec hardware decoding (4K@60fps HEVC) | [x] |
| Static image hooking | [x] |
| Network stream (RTSP) | [ ] In progress |
| Preview cropping to match aspect ratio | [x] |
| Camera1 API photo capture | [x] |
| Camera1/Camera2 API hooks | [x] |
| Audio playback | [x] |
| Floating debug panel | [x] |
### System-Wide HAL Hook (Magisk/KernelSU)
| Feature | Status |
|---------|-------|
| LD_PRELOAD injection | [x] |
| All apps simultaneous | [x] |
| No per-app scoping | [x] |
| RTSP network streams | [x] |
## Requirements
- Android 10+ (API 29+)
- Root access
- LSPosed Framework 2.0.0+ (for app-level hook)
- Magisk or KernelSU (for system-wide HAL hook)
## Usage
### Setup
1. Place video files in `DCIM`, `Movies`, or other public storage
2. Grant the module media read permissions
3. Grant target apps media read permissions
4. In LSPosed, enable CamSwapper and select target scope (TikTok, Telegram, etc.)
5. **Force stop** target app after any changes
6. Tap the thumbnail area to select a video file
### Debugging
Enable "Print Logs" in the app, then:
```bash
adb logcat | grep "VCX"
```
## System-Wide HAL Hook
For system-wide camera replacement (all apps at once), flash the module ZIP:
### Installation
```bash
# Flash camswapper-hal-hook-v1.zip in Magisk/KernelSU
adb reboot
# Configure
enabled=1
source_mode=file # or rtsp
video_path=/data/local/camera_magic/video.mp4
rtsp_url=rtsp://192.168.1.100:554/stream
```
### Verification
```bash
# Check hook loaded
adb shell getprop wrap.android.hardware.camera.provider@2.7-service-google
# Check logs
adb logcat -d -s CameraHook
```
### Uninstall
```bash
adb shell su -c "rm -rf /data/adb/modules/camera-hook"
adb shell su -c "setprop wrap.android.hardware.camera.provider@2.7-service-google ''"
adb shell su -c "rm -rf /data/local/camera_magic"
adb reboot
```
## Known Issues
- Green lines on video edges — Fixed
- Camera1 API not stopping threads after recording — Fixed
- Camera2 API hook on menu switch — Fixed
- 4K audio desync on older devices (runtime permission) — Toggle front/rear camera as workaround
## Credits
- [FFmpeg](https://ffmpeg.org/)
- [libjpeg-turbo](https://libjpeg-turbo.org/)
Executable
+1
View File
@@ -0,0 +1 @@
/build
+208
View File
@@ -0,0 +1,208 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
}
// Version control for camswapper
def majorVersion = 1
def minorVersion = 0
def patchVersion = 1
def versionCodeOffset = 0 // Offset to maintain compatibility with older versions
def getGitCommitHash = { ->
try {
return 'git rev-parse --short HEAD'.execute().text.trim()
} catch (Exception ignored) {
return "unknown"
}
}
def getGitCommitCount = { ->
try {
def count = 'git rev-list --count HEAD'.execute().text.trim()
return count.isEmpty() ? 1 : count.toInteger()
} catch (Exception ignored) {
return 1
}
}
def gitCommitHash = getGitCommitHash()
def gitCommitCount = getGitCommitCount()
def startTasks = gradle.startParameter.taskNames
def isExplicitNativeBuild = startTasks.any { it.contains("buildNative") }
android {
flavorDimensions.add("api")
productFlavors {
create("legacy") { dimension = "api" }
create("modern") { dimension = "api" }
}
namespace 'com.nothing.camera2magic'
compileSdk {
version = release(36)
}
ndkVersion = "27.0.12077973"
defaultConfig {
applicationId "com.nothing.camera2magic"
minSdk 29
targetSdk 36
// versionCode = offset + Git commit count, ensures newer than old versions
versionCode versionCodeOffset + gitCommitCount
versionName "${majorVersion}.${minorVersion}.${patchVersion}"
/*
externalNativeBuild {
cmake {
cppFlags += ""
arguments "-DANDROID_PLATFORM=android-29"
}
}
*/
ndk {
abiFilters.add("arm64-v8a")
// abiFilters.add("armeabi-v7a") // can't support android 32-bit
}
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
// Custom generated APK filename
applicationVariants.all { variant ->
variant.outputs.all { output ->
def flavor = variant.flavorName
def fileName = "CamSwapper-${majorVersion}.${minorVersion}.${patchVersion}-${flavor}-api.apk"
output.outputFileName = fileName
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_11
targetCompatibility JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = '11'
}
kotlin {
jvmToolchain(17)
}
/*
externalNativeBuild {
cmake {
path file("src/main/cpp/CMakeLists.txt")
version = "3.22.1"
}
}
*/
buildFeatures {
compose true
}
sourceSets {
main {
jniLibs {
srcDirs += ["src/main/jniLibs"]
}
}
}
packaging {
jniLibs.pickFirsts.add("**/libcamera3.so")
}
}
afterEvaluate {
if (!isExplicitNativeBuild) {
def jniLibTree = fileTree(dir: "src/main/jniLibs", include: "**/libcamera3.so")
if (jniLibTree.isEmpty()) {
println ">>> [WARN] jniLibs files not found, cannot perform quick build <<<"
return
}
println ">>> Mode: Quick build (C++ compilation disabled, using jniLibs) <<<"
// 1. Disable CMake and Strip tasks
tasks.matching { task ->
(task.name.startsWith("buildCMake") || task.name.startsWith("externalNativeBuild")) &&
!task.name.contains("generateJsonModel")
}.configureEach { task ->
task.enabled = false
}
}
}
tasks.register("buildNative", Copy) {
group = "native"
description = "Build C++ artifacts and sync to jniLibs (based on Modern Release)"
def targetVariant = "modernRelease"
def stripTaskName = "stripModernReleaseDebugSymbols"
dependsOn stripTaskName
outputs.upToDateWhen { false }
duplicatesStrategy = DuplicatesStrategy.INCLUDE
doFirst {
delete("src/main/jniLibs")
delete("release/")
delete(".cxx")
println ">>> Cleaned old jniLibs directory and C++ cache <<<"
println ">>> Mode: Native build (compiling C++ based on ${targetVariant} and updating jniLibs) <<<"
}
def startDir = layout.buildDirectory.dir("intermediates/stripped_native_libs/${targetVariant}/${stripTaskName}/out/lib")
from(startDir) {
include("**/libcamera3.so")
}
includeEmptyDirs = false
into("src/main/jniLibs")
}
dependencies {
legacyCompileOnly "de.robv.android.xposed:api:82"
modernCompileOnly 'io.github.libxposed:api:101.0.0'
modernImplementation 'io.github.libxposed:service:101.0.0'
implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.10.0"
implementation "com.google.accompanist:accompanist-permissions:0.37.3"
implementation "androidx.media3:media3-exoplayer:1.5.1"
implementation "androidx.media3:media3-common:1.5.1"
implementation libs.androidx.compose.foundation
implementation libs.androidx.compose.adaptive
implementation libs.androidx.material3
implementation libs.androidx.core.ktx
implementation libs.androidx.lifecycle.runtime.ktx
implementation libs.androidx.activity.compose
implementation platform(libs.androidx.compose.bom)
implementation libs.androidx.compose.ui
implementation libs.androidx.compose.ui.graphics
implementation libs.androidx.compose.ui.tooling.preview
implementation libs.androidx.compose.material3
testImplementation libs.junit
androidTestImplementation libs.androidx.junit
androidTestImplementation libs.androidx.espresso.core
androidTestImplementation platform(libs.androidx.compose.bom)
androidTestImplementation libs.androidx.compose.ui.test.junit4
debugImplementation libs.androidx.compose.ui.tooling
debugImplementation libs.androidx.compose.ui.test.manifest
}
Vendored Executable
+31
View File
@@ -0,0 +1,31 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
# 1. 保护所有 native 方法不被重命名或删除
-keepclasseswithmembernames class * {
native <methods>;
}
# 假设你的包名是 com.nothing.camera2magic
-keep class com.nothing.camera2magic.** {
*;
}
@@ -0,0 +1,24 @@
package com.nothing.camera2magic
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.example.virtualcamerax", appContext.packageName)
}
}
+15
View File
@@ -0,0 +1,15 @@
<?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
@@ -0,0 +1 @@
camera3
+1
View File
@@ -0,0 +1 @@
com.nothing.camera2magic.MagicHook
@@ -0,0 +1,56 @@
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--
}
})
}
}
+45
View File
@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.VirtualCameraX"
android:description="@string/app_description"
android:extractNativeLibs="false">
<meta-data
android:name="xposedmodule"
android:value="true" />
<meta-data
android:name="xposeddescription"
android:value="CamSwapper Virtual Camera Module" />
<meta-data
android:name="xposedminversion"
android:value="100" />
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.VirtualCameraX">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

@@ -0,0 +1,12 @@
package com.nothing.camera2magic
import android.content.Context
object GlobalState {
@Volatile
lateinit var appContext: Context
@Volatile
var packageName: String = ""
@Volatile
var activityCount = 0
}
+153
View File
@@ -0,0 +1,153 @@
package com.nothing.camera2magic
import android.Manifest
import android.content.Context
import android.os.Build
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.nothing.camera2magic.ui.theme.VirtualCameraXTheme
import com.nothing.camera2magic.view.SettingsView
import com.nothing.camera2magic.view.SpotlightView
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.rememberMultiplePermissionsState
import com.nothing.camera2magic.viewmodel.ConfigRepository
import com.nothing.camera2magic.viewmodel.LocalViewModelFactory
import com.nothing.camera2magic.viewmodel.ViewModelFactory
class MainActivity : ComponentActivity() {
@OptIn(ExperimentalPermissionsApi::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val prefs = getSharedPreferences("camera_magic_config", MODE_PRIVATE)
enableEdgeToEdge()
setContent {
val repository = remember { ConfigRepository(prefs) }
val factory = remember { ViewModelFactory(application, repository) }
VirtualCameraXTheme(dynamicColor = true) {
CompositionLocalProvider(LocalViewModelFactory provides factory) {
val permissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
listOf(
Manifest.permission.READ_MEDIA_IMAGES,
Manifest.permission.READ_MEDIA_VIDEO
)
} else {
listOf(Manifest.permission.READ_EXTERNAL_STORAGE)
}
// 2. Create and remember permission state
val permissionState = rememberMultiplePermissionsState(permissions)
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
if(permissionState.allPermissionsGranted) {
MainScreen()
} else {
PermissionRationaleScreen(
onGrantPermissionClick = { permissionState.launchMultiplePermissionRequest() }
)
}
}
}
}
}
}
}
@Composable
private fun PermissionRationaleScreen(onGrantPermissionClick: () -> Unit) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = stringResource(R.string.permission_rationale_title),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.permission_rationale_description),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(24.dp))
Button(onClick = onGrantPermissionClick) {
Text(text = stringResource(R.string.grant_permission_button_name))
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun AppTopBar() {
// Use CenterAlignedTopAppBar to ensure title content is centered
CenterAlignedTopAppBar(
title = {
// Row layout is used to display icon and text side-by-side
Row(verticalAlignment = Alignment.CenterVertically) {
Image(
painter = painterResource(R.drawable.icon),
contentDescription = "App Logo",
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Cam2 Magic",
fontSize = 20.sp,
fontWeight = FontWeight.Bold
)
}
}
)
}
@Composable
private fun MainScreen() {
val scrollState = rememberScrollState()
Scaffold(topBar = { AppTopBar() },
containerColor = MaterialTheme.colorScheme.background,
modifier = Modifier.statusBarsPadding()
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.padding(horizontal = 16.dp)
.verticalScroll(scrollState),
verticalArrangement = Arrangement.spacedBy(20.dp) // Use Arrangement for uniform spacing
) {
SpotlightView()
SettingsView()
Spacer(modifier = Modifier.height(0.dp)) // Optional bottom spacing
}
}
}
@@ -0,0 +1,48 @@
package com.nothing.camera2magic.hook
import android.graphics.ImageFormat
import android.graphics.SurfaceTexture
import android.media.ImageReader
import android.os.Handler
import android.os.HandlerThread
import android.view.Surface
import com.nothing.camera2magic.utils.Dog
import java.util.WeakHashMap
data class BlackHole(
val identityId: Int,
val surface: Surface,
val texture: SurfaceTexture
)
private const val TAG = "[BlackHole]"
object BlackHoleMapper {
private val oabMap = WeakHashMap<Surface, BlackHole>()
fun createBlackHole(origin: Surface): Surface {
return oabMap.getOrPut(origin) {
val id = 20 + oabMap.size
val (w, h, f) = NativeBridge.getSurfaceInfo(origin)
val targetW = if (w > 0) w else 1280
val targetH = if (h > 0) h else 720
Dog.i(TAG, "Creating proxy SurfaceTexture for $targetW x $targetH", true)
val texture = SurfaceTexture(0).apply {
detachFromGLContext()
setDefaultBufferSize(targetW, targetH)
}
val proxySurface = Surface(texture)
BlackHole(id, proxySurface, texture)
}.surface
}
fun getBlackHole(origin: Surface): Surface? {
return oabMap[origin]?.surface
}
fun clearAll() {
oabMap.values.forEach {
it.surface.release()
it.texture.release()
}
oabMap.clear()
}
}
@@ -0,0 +1,16 @@
package com.nothing.camera2magic.hook
import android.view.Surface
import android.hardware.camera2.CameraDevice
data class CameraState(
var packageName: String = "",
var apiLevel: Int = 0,
var facingFront: Boolean = false,
var pictureWidth: Int = 0,
var pictureHeight: Int = 0,
var previewWidth: Int = 0,
var previewHeight: Int = 0,
var sensorOrientation: Int = 90,
var displayOrientation: Int = 0,
val surfaces: MutableSet<Surface> = mutableSetOf()
)
@@ -0,0 +1,117 @@
package com.nothing.camera2magic.hook
import android.util.Log
import java.io.File
private const val TAG = "HalConfigManager"
private const val CONFIG_DIR = "/data/local/camera_magic"
private const val CONFIG_FILE = "$CONFIG_DIR/config.txt"
object HalConfigManager {
fun isRootAvailable(): Boolean {
return try {
val process = Runtime.getRuntime().exec("su -c id")
val exitCode = process.waitFor()
exitCode == 0
} catch (e: Exception) {
Log.w(TAG, "Root check failed", e)
false
}
}
fun writeConfig(
enabled: Boolean,
sourceMode: String,
videoPath: String,
rtspUrl: String
): Boolean {
if (!isRootAvailable()) {
Log.e(TAG, "Root access not available, cannot write HAL config")
return false
}
val configContent = buildConfigContent(enabled, sourceMode, videoPath, rtspUrl)
return try {
val mkdirProcess = Runtime.getRuntime().exec("su -c mkdir -p $CONFIG_DIR")
mkdirProcess.waitFor()
val tempFile = File.createTempFile("camera_magic_config", ".txt")
tempFile.writeText(configContent)
val copyProcess = Runtime.getRuntime().exec(
"su -c cp ${tempFile.absolutePath} $CONFIG_FILE"
)
val copyExit = copyProcess.waitFor()
tempFile.delete()
if (copyExit == 0) {
val chmodProcess = Runtime.getRuntime().exec("su -c chmod 644 $CONFIG_FILE")
chmodProcess.waitFor()
Log.i(TAG, "HAL config written successfully")
true
} else {
Log.e(TAG, "Failed to copy config file, exit code: $copyExit")
false
}
} catch (e: Exception) {
Log.e(TAG, "Failed to write HAL config", e)
false
}
}
fun readConfig(): HalConfig {
return try {
val process = Runtime.getRuntime().exec("su -c cat $CONFIG_FILE")
val exitCode = process.waitFor()
if (exitCode == 0) {
val content = process.inputStream.bufferedReader().readText()
parseConfigContent(content)
} else {
HalConfig()
}
} catch (e: Exception) {
Log.w(TAG, "Failed to read HAL config", e)
HalConfig()
}
}
private fun buildConfigContent(
enabled: Boolean,
sourceMode: String,
videoPath: String,
rtspUrl: String
): String {
return buildString {
appendLine("enabled=${if (enabled) "1" else "0"}")
appendLine("source_mode=$sourceMode")
appendLine("video_path=$videoPath")
appendLine("rtsp_url=$rtspUrl")
}
}
private fun parseConfigContent(content: String): HalConfig {
val config = HalConfig()
for (line in content.lineSequence()) {
val trimmed = line.trim()
if (trimmed.isEmpty() || trimmed.startsWith('#')) continue
val parts = trimmed.split('=', limit = 2)
if (parts.size != 2) continue
when (parts[0]) {
"enabled" -> config.enabled = parts[1] == "1"
"source_mode" -> config.sourceMode = parts[1]
"video_path" -> config.videoPath = parts[1]
"rtsp_url" -> config.rtspUrl = parts[1]
}
}
return config
}
data class HalConfig(
var enabled: Boolean = false,
var sourceMode: String = "file",
var videoPath: String = "/data/local/camera_magic/video.mp4",
var rtspUrl: String = ""
)
}
@@ -0,0 +1,88 @@
@file:Suppress("DEPRECATION")
package com.nothing.camera2magic.hook
import android.graphics.Bitmap
import android.hardware.Camera
import android.view.Surface
import com.nothing.camera2magic.utils.Dog
import java.lang.ref.WeakReference
import java.util.WeakHashMap
object NativeBridge {
private const val TAG = "[Bridge]"
@Volatile
private var lastRegisteredSurface: WeakReference<Surface>? = null
private val surfaceLock = Any()
@Volatile
private var cachedBuffer: ByteArray? = null
@Volatile
var currentCamera: WeakReference<Camera>? = null
@Volatile
var previewCallback: WeakReference<Camera.PreviewCallback>? = null
@JvmStatic
fun ensureBuffer(size: Int): ByteArray {
if (cachedBuffer != null && cachedBuffer!!.size == size) {
return cachedBuffer!!
}
return ByteArray(size).also { cachedBuffer = it }
}
@JvmStatic
fun frameUpdated(width: Int, height: Int) {
val buffer = cachedBuffer ?: return
val expectedSize = width * height * 3 / 2
if (buffer.size < expectedSize) return
runCatching {
val camera = currentCamera?.get() ?: return
val callback = previewCallback?.get() ?: return
callback.onPreviewFrame(buffer, camera)
}
}
@JvmStatic
external fun updateGlobalConfig(playSound: Boolean, enableLog: Boolean)
@JvmStatic
external fun registerSurface(cameraState: CameraState)
@JvmStatic
external fun updateManualRotation(rotation: Int)
@JvmStatic
external fun setDisplayOrientation(orientation: Int)
@JvmStatic
external fun getSurfaceInfo(surface: Surface): IntArray
@JvmStatic
external fun resetMediaSource()
@JvmStatic
external fun processVideo(fd: Int, offset: Long, length: Long): Boolean
@JvmStatic
external fun processBitmap(bitmap: Bitmap): Boolean
@JvmStatic
external fun needStopRenderer()
@JvmStatic
external fun needStartRenderer()
@JvmStatic
external fun overwritePreviewBuffer(originBuffer: ByteArray)
@JvmStatic
external fun overwriteJPEGBytes(quality: Int = 90): ByteArray
fun registerSurfaceIfNew(state: CameraState, forceRefresh: Boolean = false) {
synchronized(surfaceLock) {
val lastSurface = lastRegisteredSurface?.get()
val currentSurface = state.surfaces.firstOrNull()
currentSurface?.let { surface ->
if (forceRefresh || surface != lastSurface) {
registerSurface(cameraState = state)
lastRegisteredSurface = WeakReference(surface)
}
}
}
}
fun releaseLastRegisteredSurface() {
synchronized(surfaceLock) {
lastRegisteredSurface = null
}
}
}
@@ -0,0 +1,224 @@
package com.nothing.camera2magic.hook
import android.content.ContentUris
import android.content.SharedPreferences
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.provider.MediaStore
import com.nothing.camera2magic.GlobalState
import com.nothing.camera2magic.utils.Dog
import java.io.FileNotFoundException
object SourceManager {
private const val TAG = "[MediaSource]"
private const val LOCAL_MEDIA_TYPE_VIDEO = 0x0000
private const val LOCAL_MEDIA_TYPE_IMAGE = 0x0001
private const val NETWORK_MEDIA_TYPE_RTSP = 0x0100
private const val KEY_MODULE_ENABLED = "main_module_enabled"
private const val KEY_PLAY_SOUND = "main_play_sound"
private const val KEY_ENABLE_LOG = "main_enable_log"
private const val KEY_MEDIA_SOURCE = "media_source" // 0: local, 1: network
private const val KEY_LOCAL_MEDIA_TYPE = "local_media_type" // 0: video, 1: image
private const val KEY_LOCAL_VIDEO_ID = "local_video_id"
private const val KEY_LOCAL_IMAGE_ID = "local_image_id"
private const val KEY_NETWORK_RTSP_URI = "network_rtsp_uri"
private lateinit var prefs: SharedPreferences
private var lastMediaFingerprint: String = ""
@Volatile
var moduleEnabled: Boolean = true
private set
@Volatile
private var playSound: Boolean = false
@Volatile
var enableLog: Boolean = false
private set
@Volatile
private var mediaSource: Int = 0
@Volatile
private var mediaType: Int = 0
@Volatile
private var selectedMedia: Int = 0x0000
@Volatile
var toastMessage: String? = null
@Volatile
private var videoId: Long = -1L
@Volatile
private var imageId: Long = -1L
@Volatile
private var rtspUri: String = ""
@Volatile
var mediaIsReady: Boolean = false
private set
@Volatile
var skipNativeDispatch: Boolean = false
fun init(remotePrefs: SharedPreferences) {
this.prefs = remotePrefs
refreshPrefs()
}
fun refreshAndDispatch() {
refreshPrefs()
if (!moduleEnabled) {
updateState(false, "Module disabled")
return
}
val fingerprint = getMediaFingerprint()
if (fingerprint != lastMediaFingerprint || !mediaIsReady) {
Dog.i(TAG, "dispatching media: $fingerprint (ready=$mediaIsReady)", true)
if (skipNativeDispatch) {
mediaIsReady = true
lastMediaFingerprint = fingerprint
updateState(true, "Video ready (skipNativeDispatch)")
} else {
dispatchMediaSourceToNative()
lastMediaFingerprint = fingerprint
}
}
}
private fun refreshPrefs() {
try {
if (!::prefs.isInitialized) return
moduleEnabled = prefs.getBoolean(KEY_MODULE_ENABLED, true)
playSound = prefs.getBoolean(KEY_PLAY_SOUND, false)
enableLog = prefs.getBoolean(KEY_ENABLE_LOG, false)
mediaSource = prefs.getInt(KEY_MEDIA_SOURCE, 0)
mediaType = prefs.getInt(KEY_LOCAL_MEDIA_TYPE, 0)
selectedMedia = (mediaSource shl 8) or mediaType
videoId = prefs.getLong(KEY_LOCAL_VIDEO_ID, -1L)
imageId = prefs.getLong(KEY_LOCAL_IMAGE_ID, -1L)
rtspUri = prefs.getString(KEY_NETWORK_RTSP_URI, "") ?: ""
Dog.i(TAG, "refreshPrefs: enabled=$moduleEnabled, log=$enableLog, source=$mediaSource, type=$mediaType, selected=$selectedMedia, vId=$videoId", true)
NativeBridge.updateGlobalConfig(playSound, enableLog)
} catch (e: Exception) {
Dog.e(TAG, "refreshPrefs failed", e, true)
}
}
fun isReadyForHook(): Boolean = true // Force true for debugging
fun getVideoId(): Long = videoId
private fun dispatchMediaSourceToNative() {
mediaIsReady = false
when (selectedMedia) {
LOCAL_MEDIA_TYPE_VIDEO -> { updateVideoSource() }
LOCAL_MEDIA_TYPE_IMAGE -> { updateImageSource() }
NETWORK_MEDIA_TYPE_RTSP -> { }
}
}
private fun getMediaFingerprint(): String {
return when (selectedMedia) {
0x0000 -> "$selectedMedia:$videoId"
0x0001 -> "$selectedMedia:$imageId"
0x0100 -> "$selectedMedia:$rtspUri"
else -> ""
}
}
private fun updateVideoSource() {
if (videoId == -1L) {
NativeBridge.resetMediaSource()
updateState(false, "Video not set")
return
}
val contentResolver = GlobalState.appContext.contentResolver
val uri = ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, videoId)
val result = runCatching {
val afd = contentResolver.openAssetFileDescriptor(uri, "r")
?: throw FileNotFoundException("Unable to open video: $uri")
afd.use { it ->
NativeBridge.processVideo(
it.parcelFileDescriptor.fd,
it.startOffset,
it.length
)
}
}
result.onSuccess { success ->
val msg = if (success) "Video ready" else "Video receiving error (Native)"
updateState(success, msg)
}.onFailure { e ->
val msg = when(e) {
is SecurityException -> "No permission to read video"
else -> "Image transmission error (Java IO)"
}
updateState(false, msg)
}
}
private fun updateImageSource() {
if (imageId == -1L) {
NativeBridge.resetMediaSource()
updateState(false, "Image not set")
return
}
val uri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, imageId)
val contentResolver = GlobalState.appContext.contentResolver
val result = runCatching {
val options = BitmapFactory.Options().apply {
inJustDecodeBounds = true
contentResolver.openInputStream(uri)?.use {
BitmapFactory.decodeStream(it, null, this)
}
}
options.inJustDecodeBounds = false
options.inPreferredConfig = Bitmap.Config.ARGB_8888
options.inSampleSize = calculateInSampleSize(options, 1080, 1920)
val bitmap = contentResolver.openInputStream(uri)?.use { stream ->
BitmapFactory.decodeStream(stream, null, options)
} ?: throw IllegalStateException("Unable to decode image")
try {
NativeBridge.processBitmap(bitmap)
} finally {
bitmap.recycle()
}
}
result.onSuccess { success ->
val msg = if (success) "Image ready" else "Image receiving failed (Native)"
updateState(success, msg)
}.onFailure { e ->
val msg = when (e) {
is SecurityException -> "No permission to read image"
else -> "Image transmission error (Java IO)"
}
updateState(false, msg)
}
}
fun calculateInSampleSize(options: BitmapFactory.Options, reqWidth: Int, reqHeight: Int): Int {
val (height: Int, width: Int) = options.outHeight to options.outWidth
var inSampleSize = 1
if (height > reqHeight || width > reqWidth) {
val halfHeight = height / 2
val halfWidth = width / 2
while (halfHeight / inSampleSize >= reqHeight && halfWidth / inSampleSize >= reqWidth) {
inSampleSize *= 2
}
}
return inSampleSize
}
private fun updateState(ready: Boolean, message: String) {
mediaIsReady = ready
toastMessage = message
}
}
@@ -0,0 +1,94 @@
package com.nothing.camera2magic.hook
import android.content.ContentUris
import android.provider.MediaStore
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import com.nothing.camera2magic.GlobalState
import com.nothing.camera2magic.utils.Dog
import android.view.Surface
object VideoPusher {
private const val TAG = "VideoPusher"
private var exoPlayer: ExoPlayer? = null
private var renderer: VirtualCameraRenderer? = null
fun start(targetSurfaces: Collection<Surface>, width: Int, height: Int, videoId: Long) {
Dog.i(TAG, "Starting VideoPusher for videoId: $videoId on ${targetSurfaces.size} surfaces", true)
val context = GlobalState.appContext
val videoUri = ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, videoId)
// Dynamically detect video resolution
val retriever = android.media.MediaMetadataRetriever()
var vWidth = 0
var vHeight = 0
try {
retriever.setDataSource(context, videoUri)
vWidth = retriever.extractMetadata(android.media.MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toInt() ?: 0
vHeight = retriever.extractMetadata(android.media.MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toInt() ?: 0
val rotation = retriever.extractMetadata(android.media.MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)?.toInt() ?: 0
if (rotation == 90 || rotation == 270) {
val temp = vWidth
vWidth = vHeight
vHeight = temp
}
} catch (e: Exception) {
Dog.e(TAG, "Failed to extract video metadata: ${e.message}", e, true)
} finally {
retriever.release()
}
if (renderer == null) {
renderer = VirtualCameraRenderer()
}
renderer?.setTargetSurfaces(targetSurfaces, width, height)
if (vWidth > 0 && vHeight > 0) {
renderer?.setVideoSize(vWidth, vHeight)
}
if (exoPlayer == null) {
exoPlayer = ExoPlayer.Builder(context).build().apply {
repeatMode = Player.REPEAT_MODE_ALL
playWhenReady = true
}
}
exoPlayer?.apply {
setMediaItem(MediaItem.fromUri(videoUri))
setVideoSurface(renderer?.inputSurface)
addListener(object : Player.Listener {
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
Dog.e(TAG, "CRITICAL: ExoPlayer failed to play video. Error: ${error.message}", error, true)
}
override fun onPlaybackStateChanged(state: Int) {
val stateStr = when(state) {
Player.STATE_IDLE -> "IDLE"
Player.STATE_BUFFERING -> "BUFFERING"
Player.STATE_READY -> "READY"
Player.STATE_ENDED -> "ENDED"
else -> "UNKNOWN"
}
Dog.i(TAG, "ExoPlayer State: $stateStr", true)
}
})
prepare()
play()
}
}
fun stop() {
Dog.i(TAG, "Stopping VideoPusher")
exoPlayer?.stop()
renderer?.setTargetSurfaces(emptyList(), 0, 0)
}
fun release() {
exoPlayer?.release()
exoPlayer = null
renderer?.release()
renderer = null
}
}
@@ -0,0 +1,387 @@
package com.nothing.camera2magic.hook
import android.graphics.SurfaceTexture
import android.opengl.*
import android.opengl.EGL14.*
import android.opengl.GLES11Ext.GL_TEXTURE_EXTERNAL_OES
import android.opengl.GLES20.*
import android.os.Handler
import android.os.HandlerThread
import android.view.Surface
import com.nothing.camera2magic.utils.Dog
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.nio.FloatBuffer
class VirtualCameraRenderer : SurfaceTexture.OnFrameAvailableListener {
companion object {
private const val TAG = "VCR"
}
private val renderThread = HandlerThread("CameraRenderThread").apply { start() }
private val renderHandler = Handler(renderThread.looper)
private var eglDisplay: EGLDisplay = EGL_NO_DISPLAY
private var eglContext: EGLContext = EGL_NO_CONTEXT
private var eglSurface: EGLSurface = EGL_NO_SURFACE
private var eglConfig: EGLConfig? = null
private var surfaceTexture: SurfaceTexture? = null
private var textureId: Int = -1
private var programId: Int = -1
private var uMVPMatrixHandle: Int = -1
private var uSTMatrixHandle: Int = -1
private var aPositionHandle: Int = -1
private var aTextureCoordHandle: Int = -1
private val mvpMatrix = FloatArray(16)
private val stMatrix = FloatArray(16)
@Volatile
var inputSurface: Surface? = null
private set
private val targetSurfaces = mutableListOf<EGLSurface>()
private val surfaceSizes = mutableMapOf<EGLSurface, Pair<Int, Int>>()
private val surfaceFormats = mutableMapOf<EGLSurface, Int>()
private val imageWriters = mutableMapOf<Surface, android.media.ImageWriter>()
private var primaryWidth = 0
private var primaryHeight = 0
private var videoWidth = 0
private var videoHeight = 0
private val vertexShaderCode = """
uniform mat4 uMVPMatrix;
uniform mat4 uSTMatrix;
attribute vec4 aPosition;
attribute vec4 aTextureCoord;
varying vec2 vTextureCoord;
void main() {
gl_Position = uMVPMatrix * aPosition;
vTextureCoord = (uSTMatrix * aTextureCoord).xy;
}
""".trimIndent()
private val fragmentShaderCode = """
#extension GL_OES_EGL_image_external : require
precision mediump float;
varying vec2 vTextureCoord;
uniform samplerExternalOES sTexture;
void main() {
gl_FragColor = texture2D(sTexture, vTextureCoord);
}
""".trimIndent()
private val vertexBuffer: FloatBuffer = ByteBuffer.allocateDirect(16 * 4)
.order(ByteOrder.nativeOrder())
.asFloatBuffer()
.apply {
put(floatArrayOf(
-1.0f, -1.0f, 0.0f, 1.0f,
1.0f, -1.0f, 0.0f, 1.0f,
-1.0f, 1.0f, 0.0f, 1.0f,
1.0f, 1.0f, 0.0f, 1.0f
))
position(0)
}
private val textureBuffer: FloatBuffer = ByteBuffer.allocateDirect(16 * 4)
.order(ByteOrder.nativeOrder())
.asFloatBuffer()
.apply {
put(floatArrayOf(
0.0f, 0.0f, 0.0f, 1.0f,
1.0f, 0.0f, 0.0f, 1.0f,
0.0f, 1.0f, 0.0f, 1.0f,
1.0f, 1.0f, 0.0f, 1.0f
))
position(0)
}
private var pbufferSurface: EGLSurface = EGL_NO_SURFACE
private var pixelBuffer: ByteBuffer? = null
init {
Matrix.setIdentityM(mvpMatrix, 0)
Matrix.setIdentityM(stMatrix, 0)
renderHandler.post { setupEGL() }
}
fun setTargetSurfaces(surfaces: Collection<Surface>, width: Int, height: Int) {
renderHandler.post {
Dog.i(TAG, "setTargetSurfaces: count=${surfaces.size}, ref_w=$width, ref_h=$height", true)
// Clean up existing surfaces
targetSurfaces.forEach { eglDestroySurface(eglDisplay, it) }
targetSurfaces.clear()
surfaceSizes.clear()
surfaceFormats.clear()
primaryWidth = width
primaryHeight = height
updateMVPMatrix()
surfaces.forEach { surface ->
if (surface.isValid) {
val (w, h, f) = NativeBridge.getSurfaceInfo(surface)
// If it's a YUV/JPEG capture surface, we use ImageWriter instead of EGL
if (f == 34 || f == 35 || f == 33) {
Dog.i(TAG, "Creating ImageWriter for YUV/JPEG surface (format $f)", true)
try {
val writer = android.media.ImageWriter.newInstance(surface, 2)
imageWriters[surface] = writer
} catch (e: Exception) {
Dog.e(TAG, "Failed to create ImageWriter for format $f", e, true)
}
return@forEach
}
val surfaceAttribs = intArrayOf(EGL_NONE)
val eglSurface = eglCreateWindowSurface(eglDisplay, eglConfig, surface, surfaceAttribs, 0)
if (eglSurface != EGL_NO_SURFACE) {
targetSurfaces.add(eglSurface)
surfaceSizes[eglSurface] = Pair(w, h)
surfaceFormats[eglSurface] = f
Dog.i(TAG, "Added EGLSurface for $surface ($w x $h, format $f)", true)
} else {
val error = eglGetError()
Dog.e(TAG, "eglCreateWindowSurface failed for $surface: ${GLUtils.getEGLErrorString(error)}", null, true)
}
}
}
}
}
private fun updateMVPMatrix() {
if (primaryWidth <= 0 || primaryHeight <= 0 || videoWidth <= 0 || videoHeight <= 0) {
Matrix.setIdentityM(mvpMatrix, 0)
return
}
val surfaceRatio = primaryWidth.toFloat() / primaryHeight
val videoRatio = videoWidth.toFloat() / videoHeight
Matrix.setIdentityM(mvpMatrix, 0)
if (videoRatio > surfaceRatio) {
// Video is wider than surface - letterbox top/bottom
Matrix.scaleM(mvpMatrix, 0, 1.0f, surfaceRatio / videoRatio, 1.0f)
} else {
// Video is narrower than surface - letterbox sides
Matrix.scaleM(mvpMatrix, 0, videoRatio / surfaceRatio, 1.0f, 1.0f)
}
Dog.i(TAG, "MVP updated: sRatio=$surfaceRatio, vRatio=$videoRatio", true)
}
private fun setupEGL() {
eglDisplay = eglGetDisplay(EGL_DEFAULT_DISPLAY)
val version = IntArray(2)
eglInitialize(eglDisplay, version, 0, version, 1)
val attribList = intArrayOf(
EGL_RED_SIZE, 8,
EGL_GREEN_SIZE, 8,
EGL_BLUE_SIZE, 8,
EGL_RENDERABLE_TYPE, EGL_OPENGL_ES2_BIT,
EGL_NONE
)
val configs = arrayOfNulls<EGLConfig>(1)
val numConfigs = IntArray(1)
eglChooseConfig(eglDisplay, attribList, 0, configs, 0, configs.size, numConfigs, 0)
eglConfig = configs[0]
val contextAttribs = intArrayOf(EGL_CONTEXT_CLIENT_VERSION, 2, EGL_NONE)
eglContext = eglCreateContext(eglDisplay, eglConfig, EGL_NO_CONTEXT, contextAttribs, 0)
val pbufferAttribs = intArrayOf(EGL_WIDTH, 1, EGL_HEIGHT, 1, EGL_NONE)
pbufferSurface = eglCreatePbufferSurface(eglDisplay, eglConfig, pbufferAttribs, 0)
eglMakeCurrent(eglDisplay, pbufferSurface, pbufferSurface, eglContext)
setupShaders()
setupTexture()
surfaceTexture = SurfaceTexture(textureId).apply {
setOnFrameAvailableListener(this@VirtualCameraRenderer, renderHandler)
}
inputSurface = Surface(surfaceTexture)
Dog.i(TAG, "EGL Setup complete. inputSurface ready.", true)
}
private fun setupShaders() {
val vertexShader = loadShader(GL_VERTEX_SHADER, vertexShaderCode)
val fragmentShader = loadShader(GL_FRAGMENT_SHADER, fragmentShaderCode)
programId = glCreateProgram().also {
glAttachShader(it, vertexShader)
glAttachShader(it, fragmentShader)
glLinkProgram(it)
}
uMVPMatrixHandle = glGetUniformLocation(programId, "uMVPMatrix")
uSTMatrixHandle = glGetUniformLocation(programId, "uSTMatrix")
aPositionHandle = glGetAttribLocation(programId, "aPosition")
aTextureCoordHandle = glGetAttribLocation(programId, "aTextureCoord")
}
private fun setupTexture() {
val textures = IntArray(1)
glGenTextures(1, textures, 0)
textureId = textures[0]
glBindTexture(GL_TEXTURE_EXTERNAL_OES, textureId)
glTexParameterf(GL_TEXTURE_EXTERNAL_OES, GL_TEXTURE_MIN_FILTER, GL_LINEAR.toFloat())
glTexParameterf(GL_TEXTURE_EXTERNAL_OES, GL_TEXTURE_MAG_FILTER, GL_LINEAR.toFloat())
glTexParameteri(GL_TEXTURE_EXTERNAL_OES, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE)
glTexParameteri(GL_TEXTURE_EXTERNAL_OES, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE)
}
private fun loadShader(type: Int, shaderCode: String): Int {
return glCreateShader(type).also {
glShaderSource(it, shaderCode)
glCompileShader(it)
}
}
override fun onFrameAvailable(st: SurfaceTexture) {
renderHandler.post {
if (targetSurfaces.isEmpty()) return@post
st.updateTexImage()
st.getTransformMatrix(stMatrix)
val timestamp = st.timestamp
val it = targetSurfaces.iterator()
while (it.hasNext()) {
val eglSurface = it.next()
if (!eglMakeCurrent(eglDisplay, eglSurface, eglSurface, eglContext)) {
val error = eglGetError()
Dog.e(TAG, "eglMakeCurrent failed for surface, removing: ${GLUtils.getEGLErrorString(error)}", null, true)
eglDestroySurface(eglDisplay, eglSurface)
it.remove()
continue
}
val size = surfaceSizes[eglSurface] ?: Pair(primaryWidth, primaryHeight)
glViewport(0, 0, size.first, size.second)
glClearColor(0.0f, 0.0f, 0.0f, 1.0f)
glClear(GL_COLOR_BUFFER_BIT)
glUseProgram(programId)
glUniformMatrix4fv(uMVPMatrixHandle, 1, false, mvpMatrix, 0)
glUniformMatrix4fv(uSTMatrixHandle, 1, false, stMatrix, 0)
glEnableVertexAttribArray(aPositionHandle)
glVertexAttribPointer(aPositionHandle, 4, GL_FLOAT, false, 16, vertexBuffer)
glEnableVertexAttribArray(aTextureCoordHandle)
glVertexAttribPointer(aTextureCoordHandle, 4, GL_FLOAT, false, 16, textureBuffer)
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4)
glDisableVertexAttribArray(aPositionHandle)
glDisableVertexAttribArray(aTextureCoordHandle)
if (timestamp > 0) {
EGLExt.eglPresentationTimeANDROID(eglDisplay, eglSurface, timestamp)
}
eglSwapBuffers(eglDisplay, eglSurface)
}
// Feed YUV ImageWriters
if (imageWriters.isNotEmpty()) {
feedImageWriters(st)
}
}
}
private fun feedImageWriters(st: SurfaceTexture) {
val width = primaryWidth
val height = primaryHeight
if (width <= 0 || height <= 0) return
// 1. Draw to offscreen pbuffer for reading
eglMakeCurrent(eglDisplay, pbufferSurface, pbufferSurface, eglContext)
// Need to resize pbuffer or use an FBO for correct sizing.
// For now, we assume the pbuffer is used as a state-holder and we use an FBO if needed.
// Simplified: Read from the last drawn EGL surface if available.
if (targetSurfaces.isNotEmpty()) {
eglMakeCurrent(eglDisplay, targetSurfaces[0], targetSurfaces[0], eglContext)
}
val size = width * height * 4
if (pixelBuffer == null || pixelBuffer!!.capacity() < size) {
pixelBuffer = ByteBuffer.allocateDirect(size).order(ByteOrder.nativeOrder())
}
pixelBuffer!!.rewind()
glReadPixels(0, 0, width, height, GL_RGBA, GL_UNSIGNED_BYTE, pixelBuffer)
imageWriters.forEach { (surface, writer) ->
try {
val image = writer.dequeueInputImage()
val planes = image.planes
// Fast RGB to YUV conversion (simplified I420)
val yBuffer = planes[0].buffer
val uBuffer = planes[1].buffer
val vBuffer = planes[2].buffer
val yStride = planes[0].rowStride
val uvStride = planes[1].rowStride
val uvPixelStride = planes[1].pixelStride
pixelBuffer!!.rewind()
for (y in 0 until height) {
for (x in 0 until width) {
val r = pixelBuffer!!.get().toInt() and 0xFF
val g = pixelBuffer!!.get().toInt() and 0xFF
val b = pixelBuffer!!.get().toInt() and 0xFF
pixelBuffer!!.get() // skip A
// Y = 0.299R + 0.587G + 0.114B
val yVal = ((66 * r + 129 * g + 25 * b + 128) shr 8) + 16
yBuffer.put(y * yStride + x, yVal.toByte())
if (y % 2 == 0 && x % 2 == 0) {
// U = -0.169R - 0.331G + 0.500B
val uVal = ((-38 * r - 74 * g + 112 * b + 128) shr 8) + 128
// V = 0.500R - 0.419G - 0.081B
val vVal = ((112 * r - 94 * g - 18 * b + 128) shr 8) + 128
uBuffer.put((y / 2) * uvStride + (x / 2) * uvPixelStride, uVal.toByte())
vBuffer.put((y / 2) * uvStride + (x / 2) * uvPixelStride, vVal.toByte())
}
}
}
writer.queueInputImage(image)
} catch (e: Exception) {
// Ignore if writer is full or surface is gone
}
}
}
fun setVideoSize(width: Int, height: Int) {
renderHandler.post {
Dog.i(TAG, "setVideoSize: $width x $height", true)
videoWidth = width
videoHeight = height
updateMVPMatrix()
}
}
fun release() {
renderHandler.post {
targetSurfaces.forEach { eglDestroySurface(eglDisplay, it) }
targetSurfaces.clear()
surfaceSizes.clear()
if (eglContext != EGL_NO_CONTEXT) {
eglDestroyContext(eglDisplay, eglContext)
eglContext = EGL_NO_CONTEXT
}
if (eglDisplay != EGL_NO_DISPLAY) {
eglTerminate(eglDisplay)
eglDisplay = EGL_NO_DISPLAY
}
inputSurface?.release()
surfaceTexture?.release()
renderThread.quit()
}
}
}
@@ -0,0 +1,226 @@
package com.nothing.camera2magic.ui.theme
import androidx.compose.ui.graphics.Color
val primaryLight = Color(0xFF65558F)
val onPrimaryLight = Color(0xFFFFFFFF)
val primaryContainerLight = Color(0xFFEADDFF)
val onPrimaryContainerLight = Color(0xFF4F378B)
val secondaryLight = Color(0xFF625B71)
val onSecondaryLight = Color(0xFFFFFFFF)
val secondaryContainerLight = Color(0xFFE8DEF8)
val onSecondaryContainerLight = Color(0xFF4A4458)
val tertiaryLight = Color(0xFF7D5260)
val onTertiaryLight = Color(0xFFFFFFFF)
val tertiaryContainerLight = Color(0xFFFFD8E4)
val onTertiaryContainerLight = Color(0xFF633B48)
val errorLight = Color(0xFFB3261E)
val onErrorLight = Color(0xFFFFFFFF)
val errorContainerLight = Color(0xFFF9DEDC)
val onErrorContainerLight = Color(0xFF8C1D18)
val backgroundLight = Color(0xFFFEF7FF)
val onBackgroundLight = Color(0xFF1D1B20)
val surfaceLight = Color(0xFFFEF7FF)
val onSurfaceLight = Color(0xFF1D1B20)
val surfaceVariantLight = Color(0xFFE7E0EC)
val onSurfaceVariantLight = Color(0xFF49454F)
val outlineLight = Color(0xFF79747E)
val outlineVariantLight = Color(0xFFCAC4D0)
val scrimLight = Color(0xFF000000)
val inverseSurfaceLight = Color(0xFF322F35)
val inverseOnSurfaceLight = Color(0xFFF5EFF7)
val inversePrimaryLight = Color(0xFFD0BCFF)
val surfaceDimLight = Color(0xFFDED8E1)
val surfaceBrightLight = Color(0xFFFEF7FF)
val surfaceContainerLowestLight = Color(0xFFFFFFFF)
val surfaceContainerLowLight = Color(0xFFF7F2FA)
val surfaceContainerLight = Color(0xFFF3EDF7)
val surfaceContainerHighLight = Color(0xFFECE6F0)
val surfaceContainerHighestLight = Color(0xFFE6E0E9)
val primaryLightMediumContrast = Color(0xFF3C2D63)
val onPrimaryLightMediumContrast = Color(0xFFFFFFFF)
val primaryContainerLightMediumContrast = Color(0xFF74649F)
val onPrimaryContainerLightMediumContrast = Color(0xFFFFFFFF)
val secondaryLightMediumContrast = Color(0xFF3C2D63)
val onSecondaryLightMediumContrast = Color(0xFFFFFFFF)
val secondaryContainerLightMediumContrast = Color(0xFF74649F)
val onSecondaryContainerLightMediumContrast = Color(0xFFFFFFFF)
val tertiaryLightMediumContrast = Color(0xFF5B2238)
val onTertiaryLightMediumContrast = Color(0xFFFFFFFF)
val tertiaryContainerLightMediumContrast = Color(0xFF9C5870)
val onTertiaryContainerLightMediumContrast = Color(0xFFFFFFFF)
val errorLightMediumContrast = Color(0xFF5E2320)
val onErrorLightMediumContrast = Color(0xFFFFFFFF)
val errorContainerLightMediumContrast = Color(0xFFA15853)
val onErrorContainerLightMediumContrast = Color(0xFFFFFFFF)
val backgroundLightMediumContrast = Color(0xFFFDF7FF)
val onBackgroundLightMediumContrast = Color(0xFF1D1B20)
val surfaceLightMediumContrast = Color(0xFFFDF7FF)
val onSurfaceLightMediumContrast = Color(0xFF121016)
val surfaceVariantLightMediumContrast = Color(0xFFE6E0EC)
val onSurfaceVariantLightMediumContrast = Color(0xFF37353E)
val outlineLightMediumContrast = Color(0xFF54515A)
val outlineVariantLightMediumContrast = Color(0xFF6F6B75)
val scrimLightMediumContrast = Color(0xFF000000)
val inverseSurfaceLightMediumContrast = Color(0xFF322F35)
val inverseOnSurfaceLightMediumContrast = Color(0xFFF5EFF7)
val inversePrimaryLightMediumContrast = Color(0xFFCFBDFE)
val surfaceDimLightMediumContrast = Color(0xFFCAC5CD)
val surfaceBrightLightMediumContrast = Color(0xFFFDF7FF)
val surfaceContainerLowestLightMediumContrast = Color(0xFFFFFFFF)
val surfaceContainerLowLightMediumContrast = Color(0xFFF7F2FA)
val surfaceContainerLightMediumContrast = Color(0xFFECE6EE)
val surfaceContainerHighLightMediumContrast = Color(0xFFE0DBE3)
val surfaceContainerHighestLightMediumContrast = Color(0xFFD5D0D8)
val primaryLightHighContrast = Color(0xFF312259)
val onPrimaryLightHighContrast = Color(0xFFFFFFFF)
val primaryContainerLightHighContrast = Color(0xFF4F4078)
val onPrimaryContainerLightHighContrast = Color(0xFFFFFFFF)
val secondaryLightHighContrast = Color(0xFF312259)
val onSecondaryLightHighContrast = Color(0xFFFFFFFF)
val secondaryContainerLightHighContrast = Color(0xFF4F4078)
val onSecondaryContainerLightHighContrast = Color(0xFFFFFFFF)
val tertiaryLightHighContrast = Color(0xFF4F182E)
val onTertiaryLightHighContrast = Color(0xFFFFFFFF)
val tertiaryContainerLightHighContrast = Color(0xFF72354C)
val onTertiaryContainerLightHighContrast = Color(0xFFFFFFFF)
val errorLightHighContrast = Color(0xFF511917)
val onErrorLightHighContrast = Color(0xFFFFFFFF)
val errorContainerLightHighContrast = Color(0xFF763632)
val onErrorContainerLightHighContrast = Color(0xFFFFFFFF)
val backgroundLightHighContrast = Color(0xFFFDF7FF)
val onBackgroundLightHighContrast = Color(0xFF1D1B20)
val surfaceLightHighContrast = Color(0xFFFDF7FF)
val onSurfaceLightHighContrast = Color(0xFF000000)
val surfaceVariantLightHighContrast = Color(0xFFE6E0EC)
val onSurfaceVariantLightHighContrast = Color(0xFF000000)
val outlineLightHighContrast = Color(0xFF2D2B33)
val outlineVariantLightHighContrast = Color(0xFF4A4851)
val scrimLightHighContrast = Color(0xFF000000)
val inverseSurfaceLightHighContrast = Color(0xFF322F35)
val inverseOnSurfaceLightHighContrast = Color(0xFFFFFFFF)
val inversePrimaryLightHighContrast = Color(0xFFCFBDFE)
val surfaceDimLightHighContrast = Color(0xFFBCB7BF)
val surfaceBrightLightHighContrast = Color(0xFFFDF7FF)
val surfaceContainerLowestLightHighContrast = Color(0xFFFFFFFF)
val surfaceContainerLowLightHighContrast = Color(0xFFF5EFF7)
val surfaceContainerLightHighContrast = Color(0xFFE6E1E9)
val surfaceContainerHighLightHighContrast = Color(0xFFD8D3DA)
val surfaceContainerHighestLightHighContrast = Color(0xFFCAC5CD)
val primaryDark = Color(0xFFD0BCFE)
val onPrimaryDark = Color(0xFF381E72)
val primaryContainerDark = Color(0xFF4F378B)
val onPrimaryContainerDark = Color(0xFFEADDFF)
val secondaryDark = Color(0xFFCCC2DC)
val onSecondaryDark = Color(0xFF332D41)
val secondaryContainerDark = Color(0xFF4A4458)
val onSecondaryContainerDark = Color(0xFFE8DEF8)
val tertiaryDark = Color(0xFFEFB8C8)
val onTertiaryDark = Color(0xFF492532)
val tertiaryContainerDark = Color(0xFF633B48)
val onTertiaryContainerDark = Color(0xFFFFD8E4)
val errorDark = Color(0xFFF2B8B5)
val onErrorDark = Color(0xFF601410)
val errorContainerDark = Color(0xFF8C1D18)
val onErrorContainerDark = Color(0xFFF9DEDC)
val backgroundDark = Color(0xFF141218)
val onBackgroundDark = Color(0xFFE6E0E9)
val surfaceDark = Color(0xFF141218)
val onSurfaceDark = Color(0xFFE6E0E9)
val surfaceVariantDark = Color(0xFF49454F)
val onSurfaceVariantDark = Color(0xFFCAC4D0)
val outlineDark = Color(0xFF938F99)
val outlineVariantDark = Color(0xFF49454F)
val scrimDark = Color(0xFF000000)
val inverseSurfaceDark = Color(0xFFE6E0E9)
val inverseOnSurfaceDark = Color(0xFF322F35)
val inversePrimaryDark = Color(0xFF6750A4)
val surfaceDimDark = Color(0xFF141218)
val surfaceBrightDark = Color(0xFF3B383E)
val surfaceContainerLowestDark = Color(0xFF0F0D13)
val surfaceContainerLowDark = Color(0xFF1D1B20)
val surfaceContainerDark = Color(0xFF211F26)
val surfaceContainerHighDark = Color(0xFF2B2930)
val surfaceContainerHighestDark = Color(0xFF36343B)
val primaryDarkMediumContrast = Color(0xFFE3D6FF)
val onPrimaryDarkMediumContrast = Color(0xFF2B1B52)
val primaryContainerDarkMediumContrast = Color(0xFF9887C5)
val onPrimaryContainerDarkMediumContrast = Color(0xFF000000)
val secondaryDarkMediumContrast = Color(0xFFE3D6FF)
val onSecondaryDarkMediumContrast = Color(0xFF2B1B52)
val secondaryContainerDarkMediumContrast = Color(0xFF9887C5)
val onSecondaryContainerDarkMediumContrast = Color(0xFF000000)
val tertiaryDarkMediumContrast = Color(0xFFFFD0DD)
val onTertiaryDarkMediumContrast = Color(0xFF471228)
val tertiaryContainerDarkMediumContrast = Color(0xFFC57B93)
val onTertiaryContainerDarkMediumContrast = Color(0xFF000000)
val errorDarkMediumContrast = Color(0xFFFFD2CE)
val onErrorDarkMediumContrast = Color(0xFF481311)
val errorContainerDarkMediumContrast = Color(0xFFCC7B74)
val onErrorContainerDarkMediumContrast = Color(0xFF000000)
val backgroundDarkMediumContrast = Color(0xFF141218)
val onBackgroundDarkMediumContrast = Color(0xFFE6E0E9)
val surfaceDarkMediumContrast = Color(0xFF141318)
val onSurfaceDarkMediumContrast = Color(0xFFFFFFFF)
val surfaceVariantDarkMediumContrast = Color(0xFF48454E)
val onSurfaceVariantDarkMediumContrast = Color(0xFFE0DAE5)
val outlineDarkMediumContrast = Color(0xFFB5B0BB)
val outlineVariantDarkMediumContrast = Color(0xFF938F99)
val scrimDarkMediumContrast = Color(0xFF000000)
val inverseSurfaceDarkMediumContrast = Color(0xFFE6E1E9)
val inverseOnSurfaceDarkMediumContrast = Color(0xFF2B292F)
val inversePrimaryDarkMediumContrast = Color(0xFF4E3F77)
val surfaceDimDarkMediumContrast = Color(0xFF141318)
val surfaceBrightDarkMediumContrast = Color(0xFF46434A)
val surfaceContainerLowestDarkMediumContrast = Color(0xFF08070B)
val surfaceContainerLowDarkMediumContrast = Color(0xFF1E1D22)
val surfaceContainerDarkMediumContrast = Color(0xFF29272D)
val surfaceContainerHighDarkMediumContrast = Color(0xFF343238)
val surfaceContainerHighestDarkMediumContrast = Color(0xFF3F3D43)
val primaryDarkHighContrast = Color(0xFFF5EDFF)
val onPrimaryDarkHighContrast = Color(0xFF000000)
val primaryContainerDarkHighContrast = Color(0xFFCBB9FA)
val onPrimaryContainerDarkHighContrast = Color(0xFF0F0033)
val secondaryDarkHighContrast = Color(0xFFF5EDFF)
val onSecondaryDarkHighContrast = Color(0xFF000000)
val secondaryContainerDarkHighContrast = Color(0xFFCBB9FA)
val onSecondaryContainerDarkHighContrast = Color(0xFF0F0033)
val tertiaryDarkHighContrast = Color(0xFFFFEBEF)
val onTertiaryDarkHighContrast = Color(0xFF000000)
val tertiaryContainerDarkHighContrast = Color(0xFFFEABC5)
val onTertiaryContainerDarkHighContrast = Color(0xFF20000D)
val errorDarkHighContrast = Color(0xFFFFECEA)
val onErrorDarkHighContrast = Color(0xFF000000)
val errorContainerDarkHighContrast = Color(0xFFFFAEA7)
val onErrorContainerDarkHighContrast = Color(0xFF220001)
val backgroundDarkHighContrast = Color(0xFF141218)
val onBackgroundDarkHighContrast = Color(0xFFE6E0E9)
val surfaceDarkHighContrast = Color(0xFF141318)
val onSurfaceDarkHighContrast = Color(0xFFFFFFFF)
val surfaceVariantDarkHighContrast = Color(0xFF48454E)
val onSurfaceVariantDarkHighContrast = Color(0xFFFFFFFF)
val outlineDarkHighContrast = Color(0xFFF4EEF9)
val outlineVariantDarkHighContrast = Color(0xFFC6C1CC)
val scrimDarkHighContrast = Color(0xFF000000)
val inverseSurfaceDarkHighContrast = Color(0xFFE6E1E9)
val inverseOnSurfaceDarkHighContrast = Color(0xFF000000)
val inversePrimaryDarkHighContrast = Color(0xFF4E3F77)
val surfaceDimDarkHighContrast = Color(0xFF141318)
val surfaceBrightDarkHighContrast = Color(0xFF524F55)
val surfaceContainerLowestDarkHighContrast = Color(0xFF000000)
val surfaceContainerLowDarkHighContrast = Color(0xFF211F24)
val surfaceContainerDarkHighContrast = Color(0xFF322F35)
val surfaceContainerHighDarkHighContrast = Color(0xFF3D3A41)
val surfaceContainerHighestDarkHighContrast = Color(0xFF48464C)
@@ -0,0 +1,297 @@
package com.nothing.camera2magic.ui.theme
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
import androidx.compose.ui.platform.LocalContext
private val lightScheme = lightColorScheme(
primary = primaryLight,
onPrimary = onPrimaryLight,
primaryContainer = primaryContainerLight,
onPrimaryContainer = onPrimaryContainerLight,
secondary = secondaryLight,
onSecondary = onSecondaryLight,
secondaryContainer = secondaryContainerLight,
onSecondaryContainer = onSecondaryContainerLight,
tertiary = tertiaryLight,
onTertiary = onTertiaryLight,
tertiaryContainer = tertiaryContainerLight,
onTertiaryContainer = onTertiaryContainerLight,
error = errorLight,
onError = onErrorLight,
errorContainer = errorContainerLight,
onErrorContainer = onErrorContainerLight,
background = backgroundLight,
onBackground = onBackgroundLight,
surface = surfaceLight,
onSurface = onSurfaceLight,
surfaceVariant = surfaceVariantLight,
onSurfaceVariant = onSurfaceVariantLight,
outline = outlineLight,
outlineVariant = outlineVariantLight,
scrim = scrimLight,
inverseSurface = inverseSurfaceLight,
inverseOnSurface = inverseOnSurfaceLight,
inversePrimary = inversePrimaryLight,
surfaceDim = surfaceDimLight,
surfaceBright = surfaceBrightLight,
surfaceContainerLowest = surfaceContainerLowestLight,
surfaceContainerLow = surfaceContainerLowLight,
surfaceContainer = surfaceContainerLight,
surfaceContainerHigh = surfaceContainerHighLight,
surfaceContainerHighest = surfaceContainerHighestLight,
)
private val darkScheme = darkColorScheme(
primary = primaryDark,
onPrimary = onPrimaryDark,
primaryContainer = primaryContainerDark,
onPrimaryContainer = onPrimaryContainerDark,
secondary = secondaryDark,
onSecondary = onSecondaryDark,
secondaryContainer = secondaryContainerDark,
onSecondaryContainer = onSecondaryContainerDark,
tertiary = tertiaryDark,
onTertiary = onTertiaryDark,
tertiaryContainer = tertiaryContainerDark,
onTertiaryContainer = onTertiaryContainerDark,
error = errorDark,
onError = onErrorDark,
errorContainer = errorContainerDark,
onErrorContainer = onErrorContainerDark,
background = backgroundDark,
onBackground = onBackgroundDark,
surface = surfaceDark,
onSurface = onSurfaceDark,
surfaceVariant = surfaceVariantDark,
onSurfaceVariant = onSurfaceVariantDark,
outline = outlineDark,
outlineVariant = outlineVariantDark,
scrim = scrimDark,
inverseSurface = inverseSurfaceDark,
inverseOnSurface = inverseOnSurfaceDark,
inversePrimary = inversePrimaryDark,
surfaceDim = surfaceDimDark,
surfaceBright = surfaceBrightDark,
surfaceContainerLowest = surfaceContainerLowestDark,
surfaceContainerLow = surfaceContainerLowDark,
surfaceContainer = surfaceContainerDark,
surfaceContainerHigh = surfaceContainerHighDark,
surfaceContainerHighest = surfaceContainerHighestDark,
)
private val mediumContrastLightColorScheme = lightColorScheme(
primary = primaryLightMediumContrast,
onPrimary = onPrimaryLightMediumContrast,
primaryContainer = primaryContainerLightMediumContrast,
onPrimaryContainer = onPrimaryContainerLightMediumContrast,
secondary = secondaryLightMediumContrast,
onSecondary = onSecondaryLightMediumContrast,
secondaryContainer = secondaryContainerLightMediumContrast,
onSecondaryContainer = onSecondaryContainerLightMediumContrast,
tertiary = tertiaryLightMediumContrast,
onTertiary = onTertiaryLightMediumContrast,
tertiaryContainer = tertiaryContainerLightMediumContrast,
onTertiaryContainer = onTertiaryContainerLightMediumContrast,
error = errorLightMediumContrast,
onError = onErrorLightMediumContrast,
errorContainer = errorContainerLightMediumContrast,
onErrorContainer = onErrorContainerLightMediumContrast,
background = backgroundLightMediumContrast,
onBackground = onBackgroundLightMediumContrast,
surface = surfaceLightMediumContrast,
onSurface = onSurfaceLightMediumContrast,
surfaceVariant = surfaceVariantLightMediumContrast,
onSurfaceVariant = onSurfaceVariantLightMediumContrast,
outline = outlineLightMediumContrast,
outlineVariant = outlineVariantLightMediumContrast,
scrim = scrimLightMediumContrast,
inverseSurface = inverseSurfaceLightMediumContrast,
inverseOnSurface = inverseOnSurfaceLightMediumContrast,
inversePrimary = inversePrimaryLightMediumContrast,
surfaceDim = surfaceDimLightMediumContrast,
surfaceBright = surfaceBrightLightMediumContrast,
surfaceContainerLowest = surfaceContainerLowestLightMediumContrast,
surfaceContainerLow = surfaceContainerLowLightMediumContrast,
surfaceContainer = surfaceContainerLightMediumContrast,
surfaceContainerHigh = surfaceContainerHighLightMediumContrast,
surfaceContainerHighest = surfaceContainerHighestLightMediumContrast,
)
private val highContrastLightColorScheme = lightColorScheme(
primary = primaryLightHighContrast,
onPrimary = onPrimaryLightHighContrast,
primaryContainer = primaryContainerLightHighContrast,
onPrimaryContainer = onPrimaryContainerLightHighContrast,
secondary = secondaryLightHighContrast,
onSecondary = onSecondaryLightHighContrast,
secondaryContainer = secondaryContainerLightHighContrast,
onSecondaryContainer = onSecondaryContainerLightHighContrast,
tertiary = tertiaryLightHighContrast,
onTertiary = onTertiaryLightHighContrast,
tertiaryContainer = tertiaryContainerLightHighContrast,
onTertiaryContainer = onTertiaryContainerLightHighContrast,
error = errorLightHighContrast,
onError = onErrorLightHighContrast,
errorContainer = errorContainerLightHighContrast,
onErrorContainer = onErrorContainerLightHighContrast,
background = backgroundLightHighContrast,
onBackground = onBackgroundLightHighContrast,
surface = surfaceLightHighContrast,
onSurface = onSurfaceLightHighContrast,
surfaceVariant = surfaceVariantLightHighContrast,
onSurfaceVariant = onSurfaceVariantLightHighContrast,
outline = outlineLightHighContrast,
outlineVariant = outlineVariantLightHighContrast,
scrim = scrimLightHighContrast,
inverseSurface = inverseSurfaceLightHighContrast,
inverseOnSurface = inverseOnSurfaceLightHighContrast,
inversePrimary = inversePrimaryLightHighContrast,
surfaceDim = surfaceDimLightHighContrast,
surfaceBright = surfaceBrightLightHighContrast,
surfaceContainerLowest = surfaceContainerLowestLightHighContrast,
surfaceContainerLow = surfaceContainerLowLightHighContrast,
surfaceContainer = surfaceContainerLightHighContrast,
surfaceContainerHigh = surfaceContainerHighLightHighContrast,
surfaceContainerHighest = surfaceContainerHighestLightHighContrast,
)
private val mediumContrastDarkColorScheme = darkColorScheme(
primary = primaryDarkMediumContrast,
onPrimary = onPrimaryDarkMediumContrast,
primaryContainer = primaryContainerDarkMediumContrast,
onPrimaryContainer = onPrimaryContainerDarkMediumContrast,
secondary = secondaryDarkMediumContrast,
onSecondary = onSecondaryDarkMediumContrast,
secondaryContainer = secondaryContainerDarkMediumContrast,
onSecondaryContainer = onSecondaryContainerDarkMediumContrast,
tertiary = tertiaryDarkMediumContrast,
onTertiary = onTertiaryDarkMediumContrast,
tertiaryContainer = tertiaryContainerDarkMediumContrast,
onTertiaryContainer = onTertiaryContainerDarkMediumContrast,
error = errorDarkMediumContrast,
onError = onErrorDarkMediumContrast,
errorContainer = errorContainerDarkMediumContrast,
onErrorContainer = onErrorContainerDarkMediumContrast,
background = backgroundDarkMediumContrast,
onBackground = onBackgroundDarkMediumContrast,
surface = surfaceDarkMediumContrast,
onSurface = onSurfaceDarkMediumContrast,
surfaceVariant = surfaceVariantDarkMediumContrast,
onSurfaceVariant = onSurfaceVariantDarkMediumContrast,
outline = outlineDarkMediumContrast,
outlineVariant = outlineVariantDarkMediumContrast,
scrim = scrimDarkMediumContrast,
inverseSurface = inverseSurfaceDarkMediumContrast,
inverseOnSurface = inverseOnSurfaceDarkMediumContrast,
inversePrimary = inversePrimaryDarkMediumContrast,
surfaceDim = surfaceDimDarkMediumContrast,
surfaceBright = surfaceBrightDarkMediumContrast,
surfaceContainerLowest = surfaceContainerLowestDarkMediumContrast,
surfaceContainerLow = surfaceContainerLowDarkMediumContrast,
surfaceContainer = surfaceContainerDarkMediumContrast,
surfaceContainerHigh = surfaceContainerHighDarkMediumContrast,
surfaceContainerHighest = surfaceContainerHighestDarkMediumContrast,
)
private val highContrastDarkColorScheme = darkColorScheme(
primary = primaryDarkHighContrast,
onPrimary = onPrimaryDarkHighContrast,
primaryContainer = primaryContainerDarkHighContrast,
onPrimaryContainer = onPrimaryContainerDarkHighContrast,
secondary = secondaryDarkHighContrast,
onSecondary = onSecondaryDarkHighContrast,
secondaryContainer = secondaryContainerDarkHighContrast,
onSecondaryContainer = onSecondaryContainerDarkHighContrast,
tertiary = tertiaryDarkHighContrast,
onTertiary = onTertiaryDarkHighContrast,
tertiaryContainer = tertiaryContainerDarkHighContrast,
onTertiaryContainer = onTertiaryContainerDarkHighContrast,
error = errorDarkHighContrast,
onError = onErrorDarkHighContrast,
errorContainer = errorContainerDarkHighContrast,
onErrorContainer = onErrorContainerDarkHighContrast,
background = backgroundDarkHighContrast,
onBackground = onBackgroundDarkHighContrast,
surface = surfaceDarkHighContrast,
onSurface = onSurfaceDarkHighContrast,
surfaceVariant = surfaceVariantDarkHighContrast,
onSurfaceVariant = onSurfaceVariantDarkHighContrast,
outline = outlineDarkHighContrast,
outlineVariant = outlineVariantDarkHighContrast,
scrim = scrimDarkHighContrast,
inverseSurface = inverseSurfaceDarkHighContrast,
inverseOnSurface = inverseOnSurfaceDarkHighContrast,
inversePrimary = inversePrimaryDarkHighContrast,
surfaceDim = surfaceDimDarkHighContrast,
surfaceBright = surfaceBrightDarkHighContrast,
surfaceContainerLowest = surfaceContainerLowestDarkHighContrast,
surfaceContainerLow = surfaceContainerLowDarkHighContrast,
surfaceContainer = surfaceContainerDarkHighContrast,
surfaceContainerHigh = surfaceContainerHighDarkHighContrast,
surfaceContainerHighest = surfaceContainerHighestDarkHighContrast,
)
@Immutable
data class ColorFamily(
val color: Color,
val onColor: Color,
val colorContainer: Color,
val onColorContainer: Color
)
val unspecified_scheme = ColorFamily(
Color.Unspecified, Color.Unspecified, Color.Unspecified, Color.Unspecified
)
@Composable
fun VirtualCameraXTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+
dynamicColor: Boolean = true,
content: @Composable() () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> darkScheme
else -> lightScheme
}
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
// 1. Set the status bar background color to transparent to blend it with the application background
window.statusBarColor = Color.Transparent.toArgb()
// 2. Set the status bar text color based on whether the theme is light or dark
// isAppearanceLightStatusBars = true -> Status bar text and icons become dark
// isAppearanceLightStatusBars = false -> Status bar text and icons become light (default)
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
}
}
MaterialTheme(
colorScheme = colorScheme,
typography = AppTypography,
content = content
)
}
@@ -0,0 +1,5 @@
package com.nothing.camera2magic.ui.theme
import androidx.compose.material3.Typography
val AppTypography = Typography()
@@ -0,0 +1,23 @@
package com.nothing.camera2magic.utils
import android.util.Log
import android.view.Surface
val Surface?.shortId : String
get() = if (this == null) "null" else "@0x${Integer.toHexString(System.identityHashCode(this))}"
object Dog {
private const val PREFIX = "[VCX]"
fun i(tag: String? = null, message: String, enabled: Boolean = true) {
Log.i("$PREFIX$tag", message)
}
fun w(tag: String? = null, message: String, enabled: Boolean = true) {
Log.w("$PREFIX$tag", message)
}
fun e(tag: String? = null, message: String, throwable: Throwable? = null, enabled: Boolean = true) {
if (throwable != null) Log.e("$PREFIX$tag", message, throwable)
else Log.e("$PREFIX$tag", message)
}
}
@@ -0,0 +1,148 @@
package com.nothing.camera2magic.view
import android.app.Application
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.nothing.camera2magic.viewmodel.SettingsViewModel
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import com.nothing.camera2magic.R
import com.nothing.camera2magic.viewmodel.LocalViewModelFactory
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun SettingsView() {
val factory = LocalViewModelFactory.current
val viewModel: SettingsViewModel = viewModel(factory = factory)
val uiState by viewModel.uiState.collectAsState()
FlowRow(
modifier = Modifier.fillMaxWidth(),
maxItemsInEachRow = 2,
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// "Play sound" button
SettingsToggleButton(
modifier = Modifier.weight(1f),
text = stringResource(R.string.play_sound_button_name),
icon = ImageVector.vectorResource(R.drawable.volume_up_24px),
isChecked = uiState.playSound,
onClick = { viewModel.onPlaySoundToggled() }
)
// "Enable Log" button
SettingsToggleButton(
modifier = Modifier.weight(1f),
text = stringResource(R.string.enable_log_button_name),
icon = ImageVector.vectorResource(R.drawable.breaking_news_24px),
isChecked = uiState.enableLog,
onClick = { viewModel.onEnableLogToggled() }
)
// "HAL Mode" button (system-wide camera hook)
SettingsToggleButton(
modifier = Modifier.weight(1f),
text = stringResource(R.string.hal_mode_button_name),
icon = ImageVector.vectorResource(R.drawable.developer_board_24px),
isChecked = uiState.halModeEnabled,
onClick = { viewModel.onHalModeToggled() }
)
// "Inject Control" buttons
/*
SettingsToggleButton(
modifier = Modifier.weight(1f),
text = stringResource(R.string.inject_control_button_name),
icon = ImageVector.vectorResource(R.drawable.control_camera_24px),
isChecked = uiState.injectMenu,
onClick = { viewModel.onInjectMenuToggled() }
)
SettingsToggleButton(
modifier = Modifier.weight(1f),
text = stringResource(R.string.manually_rotate_button_name),
icon = ImageVector.vectorResource(R.drawable.rotate_90_degrees_cw_24px),
isChecked = uiState.manuallyRotate,
onClick = { viewModel.onManuallyRotateToggled() }
)
*/
}
}
@Composable
private fun SettingsToggleButton(
modifier: Modifier = Modifier,
text: String,
icon: ImageVector,
isChecked: Boolean,
onClick: () -> Unit
) {
val containerColor = if (isChecked) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.surfaceContainer
}
val contentColor = if (isChecked) {
MaterialTheme.colorScheme.onPrimary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
Button(
onClick = onClick,
modifier = modifier
.defaultMinSize(minHeight = 58.dp)
.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(
containerColor = containerColor,
contentColor = contentColor
),
elevation = ButtonDefaults.buttonElevation(defaultElevation = 0.dp),
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 20.dp)
) {
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = text,
fontWeight = FontWeight.Medium,
maxLines = 1,
softWrap = false
)
}
}
}
@@ -0,0 +1,328 @@
package com.nothing.camera2magic.view
import android.graphics.Bitmap
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.viewmodel.compose.viewModel
import com.nothing.camera2magic.viewmodel.SpotlightViewModel
import com.nothing.camera2magic.R
import com.nothing.camera2magic.viewmodel.LocalViewModelFactory
import com.nothing.camera2magic.viewmodel.MediaSource
import com.nothing.camera2magic.viewmodel.MediaType
import kotlin.enums.EnumEntries
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SpotlightView() {
val mediaSources = MediaSource.entries
val mediaSourceLabels = stringArrayResource(R.array.media_source)
val mediaTypes = MediaType.entries
val factory = LocalViewModelFactory.current
val viewModel: SpotlightViewModel = viewModel(factory = factory)
val mediaThumbnails by viewModel.thumbnails.collectAsState()
val uiState by viewModel.uiState.collectAsState()
var pendingType by remember { mutableStateOf<MediaType?>(null) }
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) {
pendingType?.let { type ->
viewModel.onMediaSelected(type, it)
}
}
val pickMedia = { type: MediaType ->
pendingType = type
launcher.launch(type.mimeType)
}
Card(
shape = RoundedCornerShape(24.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer),
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.padding(16.dp)) {
MediaSourceSelector(
sources = mediaSources,
labels = mediaSourceLabels,
selectedIndex = uiState.selectedMediaSource.value,
onSourceSelected = { index -> viewModel.selectedMediaSourceFrom(index) }
)
Spacer(modifier = Modifier.height(16.dp))
MediaPreviewGrid(
mediaTypes = mediaTypes,
thumbnails = mediaThumbnails,
currentType = uiState.currentType,
onPickMedia = { type -> pickMedia(type) },
onClearMedia = { type -> viewModel.clearMediaBy(type)},
onTypeSelected = { type -> viewModel.setCurrentMediaType(type) }
)
ModuleSwitch(
text = stringResource(R.string.module_switch_name),
isEnabled = uiState.moduleEnabled,
onToggle = { viewModel.onModuleToggled() }
)
}
}
OnLifecycleEvent { event ->
if (event == Lifecycle.Event.ON_RESUME) {
viewModel.performHealthCheckAndRefresh()
}
}
}
@Composable
private fun MediaSourceSelector(
sources: EnumEntries<MediaSource>,
labels: Array<String>,
selectedIndex: Int,
onSourceSelected: (Int) -> Unit,
modifier: Modifier = Modifier
){
SingleChoiceSegmentedButtonRow(modifier = modifier.fillMaxWidth()) {
sources.forEachIndexed { index, source ->
SegmentedButton(
shape = SegmentedButtonDefaults.itemShape(index = index, count = sources.size),
onClick = { onSourceSelected(index) },
selected = index == selectedIndex,
enabled = source != MediaSource.NETWORK
) {
Text(labels[index])
}
}
}
}
@Composable
private fun MediaPreviewGrid(
mediaTypes: EnumEntries<MediaType>,
thumbnails: Map<MediaType, Bitmap?>,
currentType: MediaType,
onPickMedia: (MediaType) -> Unit,
onClearMedia: (MediaType) -> Unit,
onTypeSelected: (MediaType) -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
mediaTypes.forEach { type ->
Column(modifier = Modifier.weight(1f)) {
MediaThumbnailCard(
thumbnail = thumbnails[type],
mediaType = type,
onClick = { onPickMedia(type) },
onClear = { onClearMedia(type) }
)
RadioButtonRow(
selected = currentType == type,
onClick = { onTypeSelected(type) },
)
}
}
}
}
@Composable
private fun MediaThumbnailCard(
modifier: Modifier = Modifier,
mediaType: MediaType,
thumbnail: Bitmap?,
onClick: () -> Unit,
onClear: () -> Unit
) {
var isInDeleteMode by remember { mutableStateOf(false) }
fun handleOnClick() {
if (isInDeleteMode) {
isInDeleteMode = false
} else {
onClick()
}
}
fun handleOnLongClick() {
if (thumbnail != null) {
isInDeleteMode = true
}
}
fun handleOnClear() {
onClear()
isInDeleteMode = false
}
Box(
modifier = modifier
.aspectRatio(9f / 16f).clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surface)
.combinedClickable(
onClick = ::handleOnClick,
onLongClick = ::handleOnLongClick
),
contentAlignment = Alignment.Center
) {
ThumbnailContent(thumbnail, mediaType)
DeleteModeOverlay(isInDeleteMode, ::handleOnClear)
}
}
@Composable
private fun ThumbnailContent(thumbnail: Bitmap?, mediaType: MediaType) {
if (thumbnail != null) {
Image (
bitmap = thumbnail.asImageBitmap(),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
} else {
val iconResource = if (mediaType == MediaType.VIDEO) {
R.drawable.video_file_24px
} else {
R.drawable.image_24px
}
Image(
imageVector = ImageVector.vectorResource(iconResource),
contentDescription = null,
modifier = Modifier.scale(1.5f)
)
}
}
@Composable
private fun DeleteModeOverlay(visible: Boolean, onClear: () -> Unit) {
AnimatedVisibility(visible = visible, enter = fadeIn(), exit = fadeOut()) {
Box(
modifier = Modifier.fillMaxSize().background(
Brush.verticalGradient(
colors = listOf(
Color.Black.copy(alpha = 0.6f),
Color.Transparent,
Color.Transparent
)
)
)
){
IconButton(
onClick = onClear,
modifier = Modifier.align(Alignment.Center).padding(8.dp).size(28.dp)
.clip(CircleShape).background(Color.Black.copy(alpha = 0.4f))
) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.close_24px),
contentDescription = "Clear thumbnail",
tint = Color.White,
modifier = Modifier.size(18.dp)
)
}
}
}
}
@Composable
private fun RadioButtonRow(selected: Boolean, onClick: () -> Unit) {
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically) {
RadioButton(
selected = selected,
onClick = onClick,
colors = RadioButtonDefaults.colors(selectedColor = MaterialTheme.colorScheme.primary)
)
}
}
@Composable
private fun ModuleSwitch(
text: String,
isEnabled: Boolean,
onToggle: () -> Unit
) {
Column(modifier = Modifier.fillMaxWidth()) {
HorizontalDivider(
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.1f),
modifier = Modifier.padding(bottom = 8.dp)
)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.developer_board_24px),
contentDescription = null,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(12.dp))
Text(text = text, fontSize = 16.sp, fontWeight = FontWeight.Medium)
}
Switch(
checked = isEnabled,
onCheckedChange = { onToggle() },
colors = SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colorScheme.onPrimary,
checkedTrackColor = MaterialTheme.colorScheme.primary,
uncheckedThumbColor = MaterialTheme.colorScheme.outline,
uncheckedTrackColor = MaterialTheme.colorScheme.surfaceContainerHighest,
uncheckedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f)
)
)
}
}
}
@Composable
fun OnLifecycleEvent(onEvent: (event: Lifecycle.Event) -> Unit) {
val eventHandler by rememberUpdatedState(onEvent)
val lifecycleOwner by rememberUpdatedState(LocalLifecycleOwner.current)
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
eventHandler(event)
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
}
@@ -0,0 +1,10 @@
package com.nothing.camera2magic.viewmodel
import androidx.compose.runtime.staticCompositionLocalOf
val LocalConfigRepository = staticCompositionLocalOf<ConfigRepository> {
error("No ConfigRepository provided")
}
val LocalViewModelFactory = staticCompositionLocalOf<ViewModelFactory> {
error("No ViewModelFactory provided")
}
@@ -0,0 +1,89 @@
package com.nothing.camera2magic.viewmodel
import android.util.Log
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import androidx.lifecycle.ViewModel
import com.nothing.camera2magic.hook.HalConfigManager
private const val TAG = "SettingsViewModel"
class SettingsViewModel(private val repository: ConfigRepository) : ViewModel() {
private val _uiState = MutableStateFlow(SettingsUiState())
val uiState = _uiState.asStateFlow()
init {
loadInitialSettings()
}
private fun loadInitialSettings() {
_uiState.value = SettingsUiState(
playSound = repository.playSound,
enableLog = repository.enableLog,
injectMenu = repository.injectMenu,
manuallyRotate = repository.manuallyRotate,
halModeEnabled = repository.halModeEnabled
)
}
fun onPlaySoundToggled() {
_uiState.update { currentState ->
val newState = !currentState.playSound
repository.playSound = newState
currentState.copy(playSound = newState)
}
}
fun onEnableLogToggled() {
_uiState.update { currentState ->
val newState = !currentState.enableLog
repository.enableLog = newState
currentState.copy(enableLog = newState)
}
}
fun onInjectMenuToggled() {
_uiState.update { currentState ->
val newState = !currentState.injectMenu
repository.injectMenu = newState
currentState.copy(injectMenu = newState)
}
}
fun onManuallyRotateToggled() {
_uiState.update { currentState ->
val newState = !currentState.manuallyRotate
repository.manuallyRotate = newState
currentState.copy(manuallyRotate = newState)
}
}
fun onHalModeToggled() {
_uiState.update { currentState ->
val newState = !currentState.halModeEnabled
repository.halModeEnabled = newState
val success = HalConfigManager.writeConfig(
enabled = newState,
sourceMode = repository.halSourceMode,
videoPath = repository.halVideoPath,
rtspUrl = repository.halRtspUrl
)
if (success) {
Log.i(TAG, "HAL mode ${if (newState) "enabled" else "disabled"}, config written")
} else {
Log.w(TAG, "HAL mode toggled but config write failed (root required)")
}
currentState.copy(halModeEnabled = newState)
}
}
}
data class SettingsUiState(
val playSound: Boolean = false,
val enableLog: Boolean = false,
val injectMenu: Boolean = false,
val manuallyRotate: Boolean = false,
val halModeEnabled: Boolean = false
)
@@ -0,0 +1,146 @@
package com.nothing.camera2magic.viewmodel
import android.app.Application
import android.content.ContentUris
import android.graphics.Bitmap
import android.net.Uri
import android.provider.MediaStore
import android.util.Size
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.lang.Exception
data class SpotlightUiState(
val moduleEnabled: Boolean = true,
val selectedMediaSource: MediaSource = MediaSource.LOCAL,
val currentType: MediaType = MediaType.VIDEO,
)
class SpotlightViewModel(
private val app: Application,
private val repository: ConfigRepository
) : ViewModel() {
private val _thumbnails = MutableStateFlow<Map<MediaType, Bitmap?>>(emptyMap())
val thumbnails = _thumbnails.asStateFlow()
private val _uiState = MutableStateFlow(SpotlightUiState())
val uiState = _uiState.asStateFlow()
init {
loadInitialSettings()
performHealthCheckAndRefresh()
}
fun onModuleToggled() {
_uiState.update { currentState ->
val newState = !currentState.moduleEnabled
repository.moduleEnabled = newState
currentState.copy(moduleEnabled = newState)
}
}
fun selectedMediaSourceFrom(value: Int) {
val source = MediaSource.fromValue(value)
_uiState.update { currentState ->
repository.mediaSource = value
currentState.copy(selectedMediaSource = source)
}
}
fun setCurrentMediaType(type: MediaType) {
_uiState.update { currentState ->
repository.localMediaType = type.value
currentState.copy(currentType = type)
}
}
fun onMediaSelected(type: MediaType, uri: Uri?) {
if (uri == null) return
val mediaId = try {
uri.lastPathSegment?.toLongOrNull()
} catch (_: kotlin.Exception) { null }
if (mediaId != null) {
saveMediaId(type, mediaId)
loadAndVerifyMedia(type, mediaId)
}
}
fun clearMediaBy(type: MediaType) {
when (type) {
MediaType.VIDEO -> repository.videoId = -1L
MediaType.IMAGE -> repository.imageId = -1L
}
updateThumbnailState(type, null)
}
fun performHealthCheckAndRefresh() {
MediaType.entries.forEach { type ->
loadAndVerifyMedia(type)
}
}
private fun saveMediaId(type: MediaType, id: Long) {
when (type) {
MediaType.VIDEO -> repository.videoId = id
MediaType.IMAGE -> repository.imageId = id
}
}
private fun loadInitialSettings() {
_uiState.update {
it.copy(
moduleEnabled = repository.moduleEnabled,
selectedMediaSource = MediaSource.fromValue(repository.mediaSource),
currentType = MediaType.fromValue(repository.localMediaType)
)
}
}
private fun getMediaId(type: MediaType): Long {
return when (type) {
MediaType.VIDEO -> repository.videoId
MediaType.IMAGE -> repository.imageId
}
}
private fun loadAndVerifyMedia(type: MediaType, mediaIdOverride: Long? = null) {
viewModelScope.launch(Dispatchers.IO) {
val mediaId = mediaIdOverride ?: getMediaId(type)
if (mediaId == -1L) {
updateThumbnailState(type, null)
return@launch
}
var thumbnail: Bitmap? = null
var isMediaValid = false
try {
val contentUri = when (type) {
MediaType.VIDEO -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI
MediaType.IMAGE -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI
}
val uri = ContentUris.withAppendedId(contentUri, mediaId)
app.contentResolver.openFileDescriptor(uri, "r")?.use {
isMediaValid = true
thumbnail = app.contentResolver.loadThumbnail(uri, Size(720, 1280), null)
}
} catch (_: Exception) {
isMediaValid = false
}
if (isMediaValid) {
updateThumbnailState(type, thumbnail)
} else {
updateThumbnailState(type, null)
if (mediaIdOverride == null) {
saveMediaId(type, -1L)
}
}
}
}
private fun updateThumbnailState(type: MediaType, thumbnail: Bitmap?) {
_thumbnails.update { currentMap ->
currentMap + (type to thumbnail)
}
}
}
@@ -0,0 +1,24 @@
package com.nothing.camera2magic.viewmodel
import android.app.Application
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
class ViewModelFactory(
private val app: Application,
private val repository: ConfigRepository) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return when {
modelClass.isAssignableFrom(SpotlightViewModel::class.java) -> {
SpotlightViewModel(app, repository) as T
}
modelClass.isAssignableFrom(SettingsViewModel::class.java) -> {
SettingsViewModel(repository) as T
}
else -> throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}")
}
}
}
+44
View File
@@ -0,0 +1,44 @@
# libcamera3.so - CamSwapper Native Library
## Status
Pre-compiled binary. **No source code available.**
## Location
`app/src/main/jniLibs/arm64-v8a/libcamera3.so`
## What it does
- Provides JNI functions called from `NativeBridge.kt`
- Decodes video using FFmpeg/libav (VideoDecoder class)
- Manages camera state (CameraState class)
- Overwrites preview buffers with video frames
## What it does NOT do
- Does NOT hook camera HAL directly
- Does NOT provide system-wide camera replacement alone
- Requires Java-level Xposed hooks to feed it surfaces
## Options to modify
1. **Reverse-engineer** the binary (very complex, not recommended)
2. **Reimplement** from scratch (requires C++, FFmpeg, Android NDK knowledge)
3. **Keep as-is** and use Java hooks (recommended - this is what currently works)
## Build (if source existed)
```bash
cd app/src/main/jni
$ANDROID_NDK_HOME/ndk-build
# or
cd app/src/main/jni
cmake . && make
```
## Current Architecture
```
Java Hooks (Camera2Hooker) → NativeBridge.registerSurface() → libcamera3.so
VideoDecoder (decodes video)
overwritePreviewBuffer() (overwrites camera buffers)
```
## Recommendation
Keep the working Java hooking approach. The native library is a utility layer, not a HAL hooking solution.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="#FF79747E">
<path
android:fillColor="@android:color/white"
android:pathData="M280,680Q297,680 308.5,668.5Q320,657 320,640Q320,623 308.5,611.5Q297,600 280,600Q263,600 251.5,611.5Q240,623 240,640Q240,657 251.5,668.5Q263,680 280,680ZM240,520L320,520L320,280L240,280L240,520ZM440,680L720,680L720,600L440,600L440,680ZM440,520L720,520L720,440L440,440L440,520ZM440,360L720,360L720,280L440,280L440,360ZM160,840Q127,840 103.5,816.5Q80,793 80,760L80,200Q80,167 103.5,143.5Q127,120 160,120L800,120Q833,120 856.5,143.5Q880,167 880,200L880,760Q880,793 856.5,816.5Q833,840 800,840L160,840ZM160,760L800,760Q800,760 800,760Q800,760 800,760L800,200Q800,200 800,200Q800,200 800,200L160,200Q160,200 160,200Q160,200 160,200L160,760Q160,760 160,760Q160,760 160,760ZM160,760L160,760Q160,760 160,760Q160,760 160,760L160,200Q160,200 160,200Q160,200 160,200L160,200Q160,200 160,200Q160,200 160,200L160,760Q160,760 160,760Q160,760 160,760Z"/>
</vector>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval"> <!-- Define shape as oval, becomes a circle when width and height are equal -->
<solid android:color="#8A000000" /> <!-- Set a semi-transparent black background -->
</shape>
+10
View File
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="#FF79747E">
<path
android:fillColor="@android:color/white"
android:pathData="M256,760L200,704L424,480L200,256L256,200L480,424L704,200L760,256L536,480L760,704L704,760L480,536L256,760Z"/>
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="#FF79747E">
<path
android:fillColor="@android:color/white"
android:pathData="M479,601Q429,601 394,566Q359,531 359,481Q359,431 394,396Q429,361 479,361Q529,361 564,396Q599,431 599,481Q599,531 564,566Q529,601 479,601ZM479,880L309,710L366,653L480,767L593,654L649,710L479,880ZM249,651L79,481L249,311L306,368L192,482L305,595L249,651ZM365,306L309,250L479,80L649,250L592,307L478,193L365,306ZM709,651L652,594L766,480L653,367L709,311L879,481L709,651Z"/>
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="#FF79747E">
<path
android:fillColor="@android:color/white"
android:pathData="M160,840Q127,840 103.5,816.5Q80,793 80,760L80,200Q80,167 103.5,143.5Q127,120 160,120L720,120Q753,120 776.5,143.5Q800,167 800,200L800,280L880,280L880,360L800,360L800,440L880,440L880,520L800,520L800,600L880,600L880,680L800,680L800,760Q800,793 776.5,816.5Q753,840 720,840L160,840ZM160,760L720,760Q720,760 720,760Q720,760 720,760L720,200Q720,200 720,200Q720,200 720,200L160,200Q160,200 160,200Q160,200 160,200L160,760Q160,760 160,760Q160,760 160,760ZM240,680L440,680L440,520L240,520L240,680ZM480,400L640,400L640,280L480,280L480,400ZM240,480L440,480L440,280L240,280L240,480ZM480,680L640,680L640,440L480,440L480,680ZM160,200L160,200Q160,200 160,200Q160,200 160,200L160,760Q160,760 160,760Q160,760 160,760L160,760Q160,760 160,760Q160,760 160,760L160,200Q160,200 160,200Q160,200 160,200Z"/>
</vector>
+170
View File
@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>
+42
View File
@@ -0,0 +1,42 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<group android:scaleX="1.6"
android:scaleY="1.6"
android:translateX="-32.4"
android:translateY="-32.4">
<group android:scaleX="0.6"
android:scaleY="0.6"
android:translateX="21.6"
android:translateY="21.6">
<group android:scaleX="0.6"
android:scaleY="0.6"
android:translateX="21.6"
android:translateY="21.6">
<path
android:pathData="M32,39L76,39A8,8 0,0 1,84 47L84,75A8,8 0,0 1,76 83L32,83A8,8 0,0 1,24 75L24,47A8,8 0,0 1,32 39z"
android:fillColor="#6750A4"/>
<path
android:pathData="M36,33L52,33A2,2 0,0 1,54 35L54,37A2,2 0,0 1,52 39L36,39A2,2 0,0 1,34 37L34,35A2,2 0,0 1,36 33z"
android:fillColor="#6750A4"/>
<path
android:pathData="M54,61m-14,0a14,14 0,1 1,28 0a14,14 0,1 1,-28 0"
android:fillColor="#EADDFF"/>
<path
android:pathData="M54,61m-8,0a8,8 0,1 1,16 0a8,8 0,1 1,-16 0"
android:fillColor="#6750A4"/>
<path
android:pathData="M76,47m-3,0a3,3 0,1 1,6 0a3,3 0,1 1,-6 0"
android:fillColor="#EADDFF"/>
<path
android:pathData="M86,20C86,20 86,14 88,14C90,14 90,20 90,20C90,20 96,20 96,22C96,24 90,24 90,24C90,24 90,30 88,30C86,30 86,24 86,24C86,24 80,24 80,22C80,20 86,20 86,20Z"
android:fillColor="#7D5260"/>
<path
android:pathData="M76,14C76,14 76,11 77,11C78,11 78,14 78,14C78,14 81,14 81,15C81,16 78,16 78,16C78,16 78,19 77,19C76,19 76,16 76,16C76,16 73,16 73,15C73,14 76,14 76,14Z"
android:fillColor="#7D5260"/>
</group>
</group>
</group>
</vector>
+30
View File
@@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:pathData="M54,0L54,0A54,54 0,0 1,108 54L108,54A54,54 0,0 1,54 108L54,108A54,54 0,0 1,0 54L0,54A54,54 0,0 1,54 0z"
android:fillColor="#F3F0F5"/>
<path
android:pathData="M32,39L76,39A8,8 0,0 1,84 47L84,75A8,8 0,0 1,76 83L32,83A8,8 0,0 1,24 75L24,47A8,8 0,0 1,32 39z"
android:fillColor="#6750A4"/>
<path
android:pathData="M36,33L52,33A2,2 0,0 1,54 35L54,37A2,2 0,0 1,52 39L36,39A2,2 0,0 1,34 37L34,35A2,2 0,0 1,36 33z"
android:fillColor="#6750A4"/>
<path
android:pathData="M54,61m-14,0a14,14 0,1 1,28 0a14,14 0,1 1,-28 0"
android:fillColor="#EADDFF"/>
<path
android:pathData="M54,61m-8,0a8,8 0,1 1,16 0a8,8 0,1 1,-16 0"
android:fillColor="#6750A4"/>
<path
android:pathData="M76,47m-3,0a3,3 0,1 1,6 0a3,3 0,1 1,-6 0"
android:fillColor="#EADDFF"/>
<path
android:pathData="M86,20C86,20 86,14 88,14C90,14 90,20 90,20C90,20 96,20 96,22C96,24 90,24 90,24C90,24 90,30 88,30C86,30 86,24 86,24C86,24 80,24 80,22C80,20 86,20 86,20Z"
android:fillColor="#7D5260"/>
<path
android:pathData="M76,14C76,14 76,11 77,11C78,11 78,14 78,14C78,14 81,14 81,15C81,16 78,16 78,16C78,16 78,19 77,19C76,19 76,16 76,16C76,16 73,16 73,15C73,14 76,14 76,14Z"
android:fillColor="#7D5260"/>
</vector>
+10
View File
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="#FF79747E">
<path
android:fillColor="@android:color/white"
android:pathData="M200,840Q167,840 143.5,816.5Q120,793 120,760L120,200Q120,167 143.5,143.5Q167,120 200,120L760,120Q793,120 816.5,143.5Q840,167 840,200L840,760Q840,793 816.5,816.5Q793,840 760,840L200,840ZM200,760L760,760Q760,760 760,760Q760,760 760,760L760,200Q760,200 760,200Q760,200 760,200L200,200Q200,200 200,200Q200,200 200,200L200,760Q200,760 200,760Q200,760 200,760ZM240,680L720,680L570,480L450,640L360,520L240,680ZM200,760Q200,760 200,760Q200,760 200,760L200,200Q200,200 200,200Q200,200 200,200L200,200Q200,200 200,200Q200,200 200,200L200,760Q200,760 200,760Q200,760 200,760Z"/>
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="#FF79747E">
<path
android:fillColor="@android:color/white"
android:pathData="M440,880Q365,880 299.5,851.5Q234,823 185.5,774.5Q137,726 108.5,660.5Q80,595 80,520Q80,370 185,265Q290,160 440,160L446,160L384,98L440,40L600,200L440,360L384,302L446,240L440,240Q323,240 241.5,321.5Q160,403 160,520Q160,637 241.5,718.5Q323,800 440,800Q475,800 509,791.5Q543,783 574,766L632,824Q589,852 540,866Q491,880 440,880ZM680,760L440,520L680,280L920,520L680,760ZM680,646L806,520L680,394L554,520L680,646ZM680,520L680,520L680,520L680,520Z"/>
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="#FF79747E">
<path
android:fillColor="@android:color/white"
android:pathData="M360,720L520,720Q537,720 548.5,708.5Q560,697 560,680L560,640L640,682L640,518L560,560L560,520Q560,503 548.5,491.5Q537,480 520,480L360,480Q343,480 331.5,491.5Q320,503 320,520L320,680Q320,697 331.5,708.5Q343,720 360,720ZM240,880Q207,880 183.5,856.5Q160,833 160,800L160,160Q160,127 183.5,103.5Q207,80 240,80L560,80L800,320L800,800Q800,833 776.5,856.5Q753,880 720,880L240,880ZM520,360L520,160L240,160Q240,160 240,160Q240,160 240,160L240,800Q240,800 240,800Q240,800 240,800L720,800Q720,800 720,800Q720,800 720,800L720,360L520,360ZM240,160L240,160L240,360L240,360L240,160L240,360L240,360L240,800Q240,800 240,800Q240,800 240,800L240,800Q240,800 240,800Q240,800 240,800L240,160Q240,160 240,160Q240,160 240,160Z"/>
</vector>
@@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="#FF79747E"
android:autoMirrored="true">
<path
android:fillColor="@android:color/white"
android:pathData="M560,829L560,747Q650,721 705,647Q760,573 760,479Q760,385 705,311Q650,237 560,211L560,129Q684,157 762,254.5Q840,352 840,479Q840,606 762,703.5Q684,801 560,829ZM120,600L120,360L280,360L480,160L480,800L280,600L120,600ZM560,640L560,318Q607,340 633.5,384Q660,428 660,480Q660,531 633.5,574.5Q607,618 560,640ZM400,354L314,440L200,440L200,520L314,520L400,606L400,354ZM300,480L300,480L300,480L300,480L300,480L300,480Z"/>
</vector>
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 856 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">CamSwapper</string>
<string name="app_description">CamSwapper 虚拟摄像头模块,支持 Android 10+</string>
<string name="permission_rationale_title">需要媒体权限</string>
<string name="permission_rationale_description">为了加载您的视频和图片作为虚拟摄像头内容,应用需要访问您设备上的媒体文件。</string>
<string name="grant_permission_button_name">授予权限</string>
<!--spotlight-->
<string-array name="media_source">
<item>本地媒体</item>
<item>网络视频</item>
</string-array>
<string name="module_switch_name">开启模块</string>
<string name="play_sound_button_name">播放声音</string>
<string name="enable_log_button_name">打印日志</string>
<string name="inject_control_button_name">浮动控制台</string>
<string name="manually_rotate_button_name">旋转画面</string>
<string name="work_mode_normal">拍照</string>
<string name="work_mode_scan_qr_code">扫描二维码</string>
<string name="work_mode_face_recognition">人脸识别</string>
</resources>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#F3F0F5</color>
</resources>
+35
View File
@@ -0,0 +1,35 @@
<resources>
<string name="app_name">CamSwapper</string>
<string name="app_description">CamSwapper Virtual Camera Module, supports Android 10+</string>
<!-- Permission rationale page strings -->
<string name="permission_rationale_title">Media Access Required</string>
<string name="permission_rationale_description">To load your selected videos and images for the virtual camera, the app needs permission to access media files on your device.</string>
<string name="grant_permission_button_name">Grant Permission</string>
<!-- Spotlight strings -->
<string-array name="media_source">
<item>Local</item>
<item>Network</item>
</string-array>
<string-array name="local_media_type">
<item>Video</item>
<item>Image</item>
</string-array>
<string name="module_switch_name">Enable Module</string>
<!-- Settings strings -->
<string name="play_sound_button_name">Play Sound</string>
<string name="enable_log_button_name">Enable Log</string>
<string name="inject_control_button_name">Inject Menu</string>
<string name="manually_rotate_button_name">Manually Rotate</string>
<string name="work_mode_normal">Normal</string>
<string name="work_mode_scan_qr_code">Scan QR Code</string>
<string name="work_mode_face_recognition">Face Recognition</string>
<!-- HAL mode strings -->
<string name="hal_mode_button_name">HAL Mode</string>
<string name="hal_mode_status_enabled">HAL Mode: ON (system-wide)</string>
<string name="hal_mode_status_disabled">HAL Mode: OFF (app-level)</string>
<string name="hal_mode_config_success">HAL config written successfully</string>
<string name="hal_mode_config_failed">Failed to write HAL config (root required)</string>
<string name="hal_mode_root_required">Root access required for HAL mode</string>
</resources>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.VirtualCameraX" parent="android:Theme.Material.Light.NoActionBar" />
</resources>
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older than API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>
+19
View File
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>
+78
View File
@@ -0,0 +1,78 @@
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--
}
}
}
@@ -0,0 +1,275 @@
@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()
}
}
}
@@ -0,0 +1,342 @@
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))
}
}
}
@@ -0,0 +1,524 @@
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))
}
}
}
@@ -0,0 +1,49 @@
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)
}
}
}
}
@@ -0,0 +1,167 @@
package com.nothing.camera2magic.viewmodel
import android.content.SharedPreferences
import android.util.Log
import io.github.libxposed.service.XposedService
import io.github.libxposed.service.XposedServiceHelper
import androidx.core.content.edit
private const val TAG = "[VCX][ConfigRepo]"
private const val GROUP_NAME = "camera_magic_config"
enum class MediaSource(val value: Int, val label: String) {
LOCAL(0, "Local"),
NETWORK(1, "Network");
companion object {
fun fromValue(value: Int): MediaSource {
return entries.find { it.value == value }
?: throw IllegalArgumentException("Invalid MediaSource value: $value")
}
}
}
enum class MediaType(val value: Int, val mimeType: String) {
VIDEO(0, "video/*"),
IMAGE(1, "image/*");
companion object {
fun fromValue(value: Int): MediaType {
return entries.find { it.value == value }
?: throw IllegalArgumentException("Invalid MediaType value: $value")
}
}
}
class ConfigRepository(private val prefs: SharedPreferences) {
private var xposedService: XposedService? = null
init {
XposedServiceHelper.registerListener(object : XposedServiceHelper.OnServiceListener {
override fun onServiceBind(service: XposedService) {
Log.i(TAG, "XposedService bound, syncing all to remote")
xposedService = service
syncAllToRemote()
}
override fun onServiceDied(service: XposedService) {
xposedService = null
}
})
}
private fun <T> save(key: String, value: T) {
prefs.edit {
when (value) {
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")
}
}
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
get() = prefs.getBoolean("main_module_enabled", true)
set(value) = save("main_module_enabled", value)
var playSound: Boolean
get() = prefs.getBoolean("main_play_sound", false)
set(value) =save("main_play_sound", value)
var enableLog: Boolean
get() = prefs.getBoolean("main_enable_log", false)
set(value) = save("main_enable_log", value)
var injectMenu: Boolean
get() = prefs.getBoolean("main_inject_menu", false)
set(value) = save("main_inject_menu", value)
var manuallyRotate: Boolean
get() = prefs.getBoolean("main_manually_rotate", false)
set(value) = save("main_manually_rotate", value)
var mediaSource: Int
get() = prefs.getInt("media_source", 0)
set(value) {
if (value !in MediaSource.entries.map { it.value }) {
throw IllegalArgumentException("Invalid MediaSource value: $value")
} else {
save("media_source", value)
}
}
var localMediaType: Int
get() = prefs.getInt("local_media_type", 0)
set(value) {
if (value !in MediaType.entries.map { it.value }) {
throw IllegalArgumentException("Invalid MediaType value: $value")
} else {
save("local_media_type", value)
}
}
var videoId: Long
get() = prefs.getLong("local_video_id", -1L)
set(value) = save("local_video_id", value)
var imageId: Long
get() = prefs.getLong("local_image_id", -1L)
set(value) = save("local_image_id", value)
var rtspUri: String
get() = prefs.getString("network_rtsp_uri", "") ?: ""
set(value) = save("network_rtsp_uri", value)
// HAL mode configuration (system-wide camera hook)
var halModeEnabled: Boolean
get() = prefs.getBoolean("hal_mode_enabled", false)
set(value) = save("hal_mode_enabled", value)
var halVideoPath: String
get() = prefs.getString("hal_video_path", "/data/local/camera_magic/video.mp4") ?: ""
set(value) = save("hal_video_path", value)
var halRtspUrl: String
get() = prefs.getString("hal_rtsp_url", "") ?: ""
set(value) = save("hal_rtsp_url", value)
var halSourceMode: String
get() = prefs.getString("hal_source_mode", "file") ?: "file"
set(value) = save("hal_source_mode", value)
}
@@ -0,0 +1 @@
com.nothing.camera2magic.MagicHook
@@ -0,0 +1,4 @@
id=com.nothing.camera2magic
minApiVersion=101
targetApiVersion=101
staticScope=false
@@ -0,0 +1 @@
camera3
@@ -0,0 +1 @@
tv.danmaku.bili
@@ -0,0 +1,17 @@
package com.nothing.camera2magic
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}
Executable
+5
View File
@@ -0,0 +1,5 @@
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
}
+199
View File
@@ -0,0 +1,199 @@
# Buffer Sharing Design: Video → HAL
## Problem
We need to inject decoded video frames into the camera HAL's output buffer pipeline at the HAL process level (camera provider APEX), replacing real camera frames with virtual video frames.
## Architecture Context
```
[App Process] [Camera Provider APEX Process]
┌─────────────────┐ ┌──────────────────────────────────┐
│ ExoPlayer │ │ libgooglecamerahal.so │
│ ↓ decodes │ │ ↓ processCaptureRequest() │
│ MediaCodec │ │ ↓ fills output buffers │
│ ↓ YUV frames │ │ ↓ (AHardwareBuffer/gralloc) │
│ VirtualCamera │ │ cameraserver reads via binder │
│ Renderer │ └──────────────────────────────────┘
│ ↓ renders to │ ↑
│ Surface │ │ WE INJECT HERE
└─────────────────┘ ┌──────────────────────────────────┘
│ libcamera_hook.so (LD_PRELOAD) │
│ intercepts processCaptureRequest │
└──────────────────────────────────┘
```
## Evaluated Approaches
### 1. AHardwareBuffer Direct Write (CHOSEN)
**How it works**: HAL output buffers are `buffer_handle_t` backed by gralloc/AHardwareBuffer. Lock the buffer for CPU write, copy YUV frame data, unlock.
**Pros**:
- No extra memory allocation needed (reuse HAL's buffer)
- Direct write to the buffer cameraserver will read
- Works with YUV_420_888 (format 32) which is CPU-accessible
- Pixel 9a (API 36) fully supports AHardwareBuffer_lock()
**Cons**:
- CPU copy required (not zero-copy)
- IMPLEMENTATION_DEFINED (format 35) buffers may not be CPU-lockable
- Must handle buffer stride/alignment correctly
**Verdict**: **CHOSEN** for YUV_420_888 preview streams. This is the primary virtual camera use case.
### 2. Shared Memory (ashmem/memfd) Cross-Process
**How it works**: App process decodes video, writes YUV frames to shared memory. Provider process reads from shared memory and copies into HAL buffers.
**Pros**:
- Clean separation: decoder in app, injection in provider
- App has full MediaCodec/ExoPlayer access
- memfd_create() available on Android 11+ (API 30+)
**Cons**:
- Extra copy: decoder → shared memory → HAL buffer
- Cross-process synchronization complexity
- Need IPC mechanism (binder, socket, or signal)
**Verdict**: **CHOSEN** as the transport mechanism from app to provider process. Combined with Approach 1 for the final write.
### 3. ION/dmabuf
**How it works**: Allocate ION memory, pass dmabuf fd to both decoder and HAL.
**Pros**:
- Zero-copy potential
- GPU/ISP accessible
**Cons**:
- Vendor-specific ION heap configurations
- Requires /dev/ion access (SELinux restrictions in provider process)
- Complex buffer lifecycle management
- Overkill for our use case
**Verdict**: **REJECTED**. Too complex, vendor-dependent, and SELinux-hostile.
## Chosen Architecture
### Two-Stage Pipeline
```
Stage 1: App Process (Video Decoding)
┌─────────────────────────────────────┐
│ ExoPlayer → MediaCodec │
│ ↓ decoded YUV420 frames │
│ FrameRingBuffer (shared memory) │
│ ↓ memfd + mmap │
│ SharedMemoryWriter │
└─────────────────────────────────────┘
│ memfd (fd passed via binder/property)
Stage 2: Provider Process (HAL Injection)
┌─────────────────────────────────────┐
│ SharedMemoryReader │
│ ↓ reads latest YUV frame │
│ BufferConverter │
│ ↓ handles format/stride conversion │
│ AHardwareBufferWriter │
│ ↓ lock → memcpy → unlock │
│ HAL output buffer (to cameraserver) │
└─────────────────────────────────────┘
```
### Buffer Lifecycle
1. **Allocation**: HAL allocates output buffers during `configureStreams()`. We observe and record buffer dimensions, format, and stride.
2. **Frame Production**: App process decodes video via MediaCodec, writes YUV frames to a ring buffer in shared memory (memfd). Each frame has a sequence number and timestamp.
3. **Frame Consumption**: In `processCaptureRequest()`, our hook:
a. Reads the latest frame from shared memory
b. Locks the HAL output buffer via `AHardwareBuffer_lock()` (for YUV_420_888)
c. Copies frame data with stride conversion if needed
d. Unlocks the buffer
e. Returns to cameraserver (appears as real camera frame)
4. **Synchronization**:
- Shared memory ring buffer uses atomic sequence numbers
- Reader always grabs the latest complete frame (no blocking)
- If no frame available, forward to real HAL (passthrough)
### Format Handling
| Stream Format | Strategy |
|---|---|
| YUV_420_888 (32) | Direct AHardwareBuffer_lock + memcpy |
| IMPLEMENTATION_DEFINED (35) | Passthrough to real HAL (opaque GPU format) |
| JPEG/BLOB (37) | Passthrough to real HAL (encode handled by HAL) |
### Memory Layout
Shared memory ring buffer (memfd):
```
┌─────────────────────────────────────────────┐
│ Header (256 bytes) │
│ - magic: 0xCSWAPR00 │
│ - frame_count: uint32 │
│ - write_index: atomic<uint32> │
│ - read_index: atomic<uint32> │
│ - frame_width: uint32 │
│ - frame_height: uint32 │
│ - frame_size: uint32 │
├─────────────────────────────────────────────┤
│ Frame 0 (width × height × 3/2 bytes) │
│ - Y plane: width × height │
│ - U plane: width/2 × height/2 │
│ - V plane: width/2 × height/2 │
├─────────────────────────────────────────────┤
│ Frame 1 ... │
├─────────────────────────────────────────────┤
│ Frame N-1 ... │
└─────────────────────────────────────────────┘
```
Ring size: 4 frames (enough for 30fps video with slight timing jitter).
### AHardwareBuffer Write Procedure
```cpp
// In processCaptureRequest hook:
for (int i = 0; i < request->output_buffer_count; i++) {
auto& buf = request->output_buffers[i];
auto stream = find_stream(buf.stream_id);
if (stream->format == FORMAT_YUV_420_888 && g_hook_state.virtual_camera_enabled) {
// Lock buffer for CPU write
AHardwareBuffer* ahb = AHardwareBuffer_fromNativeHandle(buf.handle);
AHardwareBuffer_Desc desc;
AHardwareBuffer_describe(ahb, &desc);
void* cpu_addr = nullptr;
int result = AHardwareBuffer_lock(ahb,
AHARDWAREBUFFER_USAGE_CPU_WRITE_OFTEN,
-1, nullptr, &cpu_addr);
if (result == 0 && cpu_addr) {
// Copy YUV frame from shared memory
copy_yuv_frame_to_buffer(cpu_addr, desc.stride, desc.height,
g_frame_reader->latest_frame());
AHardwareBuffer_unlock(ahb, nullptr);
}
}
}
```
## Implementation Tasks
- **Task 7**: Video decoder (MediaCodec NDK) in app process
- **Task 9**: Buffer format conversion + shared memory ring buffer
- **Task 6**: processCaptureRequest integration (AHardwareBuffer write)
## Pixel 9a Specifics
- API level: 36 (Android 16)
- AHardwareBuffer: fully supported
- Gralloc: `/vendor/lib64/hw/gralloc.gs101.so` (Tensor G3)
- YUV_420_888: CPU-accessible, linear layout
- IMPLEMENTATION_DEFINED: opaque, GPU-only (passthrough)
+268
View File
@@ -0,0 +1,268 @@
# Pixel 9a Camera HAL Interface Map
> **Generated**: 2026-05-07
> **Device**: Pixel 9a (tegu), API 36 (Android 16)
> **Device ID**: 58241JEBF08428
---
## 1. Architecture Overview
### CRITICAL DISCOVERY: Camera HAL is a Separate Process
The Pixel 9a camera HAL is **NOT** a shared library loaded by `system_server`. It is a **separate process** running as the `system` user, communicating with `cameraserver` via **AIDL Binder IPC**.
```
┌─────────────────────────────────────────────────────────────┐
│ App Process │
│ (Google Camera, Fossify Camera, Instagram, etc.) │
│ Uses Camera2 API → Binder IPC → cameraserver │
└────────────────────────┬────────────────────────────────────┘
│ Camera2 API (Binder)
┌────────────────────────▼────────────────────────────────────┐
│ cameraserver (PID 1211) │
│ Process: /system/bin/cameraserver │
│ User: cameraserver │
│ Role: Manages camera devices, routes requests to HAL │
└────────────────────────┬────────────────────────────────────┘
│ AIDL Binder IPC (ICameraProvider)
┌────────────────────────▼────────────────────────────────────┐
│ Camera Provider HAL Process (PID 994) │
│ Binary: /apex/com.google.pixel.camera.hal/bin/hw/ │
│ android.hardware.camera.provider@2.7-service-google│
│ User: system │
│ APEX: com.google.pixel.camera.hal │
│ Interface: ICameraProvider/internal/0 (AIDL v3) │
│ Role: Implements camera HAL, talks to kernel drivers │
└────────────────────────┬────────────────────────────────────┘
│ Kernel drivers (/dev/lwis-*)
┌────────────────────────▼────────────────────────────────────┐
│ Camera Hardware │
│ Sensors, ISP, OIS, Actuator, EEPROM │
└─────────────────────────────────────────────────────────────┘
```
### Implications for HAL Hooking
The traditional "HAL wrapper library loaded by system_server" approach is **WRONG** for Pixel 9a. The correct interception strategies are:
1. **Hook the camera provider process** — Inject a library into the provider process via `wrap.` property + `LD_PRELOAD`
2. **Intercept Binder/AIDL IPC** — Hook the Binder communication between `cameraserver` and the provider
3. **Replace the APEX binary** — Swap the provider binary with a wrapper (requires APEX modification)
4. **Hook at cameraserver level** — Intercept in `cameraserver` before requests reach the provider
**Recommended approach**: Option 1 (hook the camera provider process via `wrap.` property). The provider process is the most direct interception point.
---
## 2. AIDL Interface Hierarchy
### Interface Chain
```
ICameraProvider (v3)
└── getCameraIdList() → ["device@1.1/internal/0", ...]
└── openCamera(deviceId, callback) → ICameraDevice
└── ICameraDevice (v4)
└── open(sessionCallback) → ICameraDeviceSession
└── ICameraDeviceSession (v4)
├── configureStreams(config) → StreamConfiguration
├── processCaptureRequest(request) → Status
├── flush() → Status
└── close()
```
### Key AIDL Interfaces
#### ICameraProvider (android.hardware.camera.provider-V4-ndk.so)
```aidl
interface ICameraProvider {
CameraStatus[] getCameraIdList(out String[] cameraIds);
CameraStatus isSetTorchModeSupported(String cameraId, out boolean support);
CameraStatus openCamera(String cameraId, ICameraDeviceCallback callback,
out ICameraDevice device);
CameraStatus setTorchMode(String cameraId, boolean enabled);
CameraStatus notifyDeviceStateChange(long physicalCameraId, long deviceState);
}
```
#### ICameraDevice (android.hardware.camera.device-V4-ndk.so)
```aidl
interface ICameraDevice {
CameraMetadata getCameraCharacteristics();
int getResourceCost();
CameraStatus open(ICameraDeviceCallback callback,
out ICameraDeviceSession session);
void close();
}
```
#### ICameraDeviceSession — PRIMARY INTERCEPTION TARGET
```aidl
interface ICameraDeviceSession {
// ★★★ KEY FUNCTION 1 ★★★
// Configure output/input streams for the camera
CameraStatus configureStreams(
in StreamConfiguration requestedConfiguration,
out HalStreamConfiguration halConfiguration);
// ★★★ KEY FUNCTION 2 ★★★
// Process a capture request — THIS IS WHERE WE REPLACE FRAMES
CameraStatus processCaptureRequest(
in CaptureRequest request,
out CaptureResultMetadata resultMetadata);
// ★★★ KEY FUNCTION 3 ★★★
CameraStatus flush();
void close();
CameraStatus getSignalStreamMap(out SignalStreamMap streamMap);
CameraStatus processPhysicalCaptureRequest(
in PhysicalCaptureRequestInfo physicalRequestInfo,
out CaptureResultMetadata resultMetadata);
CameraStatus setRepeatingRequests(in CaptureRequest[] requests, out int32_t sequenceId);
CameraStatus cancelRepeatingRequest(int32_t sequenceId);
}
```
---
## 3. Camera Device Configuration
### Device Mapping
| API Device | HAL ID | Facing | Description |
|---|---|---|---|
| Device 0 | HAL 2 (Rear) | Back | Main rear camera |
| Device 0 | HAL 3 (RearWide) | Back | Ultra-wide rear camera |
| Device 1 | HAL 1 (Front) | Front | Selfie camera |
### Stream Configuration Formats
From `android.scaler.availableStreamConfigurations`:
| Format Code | Format Name | Description | Max Resolution |
|---|---|---|---|
| 32 | `HAL_PIXEL_FORMAT_YCBCR_420_888` | Flexible YUV 4:2:0 | 4000×3000 |
| 35 | `HAL_PIXEL_FORMAT_IMPLEMENTATION_DEFINED` | GPU/ISP opaque format | 4208×3120 |
| 36 | `HAL_PIXEL_FORMAT_RAW16` | Raw sensor data | Varies |
| 37 | `HAL_PIXEL_FORMAT_BLOB` (JPEG) | Compressed JPEG | 4000×3000 |
**Most important format for virtual camera**: Format 32 (YUV_420_888) — this is what preview streams use and what we need to feed video frames into.
### Buffer Flow
```
1. App calls CameraDevice.createCaptureSession() with Surface targets
2. cameraserver calls ICameraDeviceSession.configureStreams()
→ HAL allocates/configures output buffers
3. App calls CaptureRequest.Builder.addTarget(surface)
4. cameraserver calls ICameraDeviceSession.processCaptureRequest()
→ HAL fills buffers with camera sensor data
→ Buffers are returned to cameraserver → app surfaces
```
**Our interception point**: Step 4 — replace buffer contents with decoded video frames BEFORE returning to cameraserver.
---
## 4. Key Libraries in APEX
### Location: `/apex/com.google.pixel.camera.hal/lib64/`
| Library | Purpose | Relevance |
|---|---|---|
| `libgooglecamerahal.so` | Main Google Camera HAL implementation | **PRIMARY TARGET** |
| `libgooglecamerahalutils.so` | HAL utility functions | Supporting |
| `android.hardware.camera.provider-V4-ndk.so` | AIDL Provider interface stubs | Interface definition |
| `android.hardware.camera.device-V4-ndk.so` | AIDL Device interface stubs | Interface definition |
| `android.hardware.camera.common-V1-ndk.so` | Common camera types | Interface definition |
| `android.hardware.camera.metadata-V3-ndk.so` | Camera metadata handling | Needed for result metadata |
| `liblyric_hwl.so` | Google Lyric Hardware Layer | Google-specific ISP |
| `libg3a.so` | 3A algorithms (AE/AWB/AF) | Auto-exposure/whitebalance/focus |
| `libcamerasuezclient.so` | Suez framework client | Google analytics/telemetry |
| `libion.so` / `libion_google.so` | ION memory allocator | Buffer allocation |
| `libdmabufheap.so` | DMA-BUF heap allocator | Buffer sharing |
| `libyuv.so` | YUV format conversion | Format conversion |
---
## 5. Interception Strategy
### Approach: Hook the Camera Provider Process
Since the camera HAL runs as a separate process, we inject our hooking library into that process.
#### Option A: wrap. Property + LD_PRELOAD (Recommended)
```bash
# Set via Magisk service.sh at boot:
setprop wrap.android.hardware.camera.provider@2.7-service-google \
"LD_PRELOAD=/data/local/camera_magic/libcamera_hook.so"
```
The `wrap.` property tells Android's init to restart the process with LD_PRELOAD.
#### Option B: Replace APEX Binary
Replace the provider binary with a wrapper that loads the original + our hooks. More invasive but more reliable.
### Functions to Hook
| Function | Library | Purpose | Hook Strategy |
|---|---|---|---|
| `configureStreams` | `libgooglecamerahal.so` | Know stream sizes/formats | Observe and store config |
| `processCaptureRequest` | `libgooglecamerahal.so` | Replace frames | Fill buffers with video data |
| `flush` | `libgooglecamerahal.so` | Cleanup | Pass through + cleanup our buffers |
### Buffer Replacement Flow
```
1. configureStreams() called → store stream config (resolution, format, buffer count)
2. Allocate our own video frame buffers (matching HAL format)
3. processCaptureRequest() called:
a. Check if virtual camera is enabled (/data/local/camera_magic/config.txt)
b. If enabled: copy decoded video frame into request's output buffer
c. If disabled: pass through to original HAL
d. Return OK status
4. cameraserver receives buffer → sends to app → app shows virtual video
```
---
## 6. SELinux Considerations
The camera provider process runs as `system` user with its own SELinux context. Injecting libraries requires:
1. SELinux policy allowing `system` process to load libraries from `/data/local/`
2. Magisk `sepolicy.rule` to add:
```
allow hal_camera_server system_data_file:file { read open execute };
```
---
## 7. Verification Commands
```bash
# Verify camera provider process
adb shell ps -A | grep camera.provider
# Check AIDL service registration
adb shell service list | grep camera
# View camera service events
adb shell dumpsys media.camera | head -30
# Check stream configurations
adb shell dumpsys media.camera | grep availableStreamConfigurations
# Monitor camera usage
adb shell dumpsys media.camera | grep "CONNECT\|DISCONNECT"
# Check loaded libraries in provider process
adb shell su -c 'cat /proc/$(pidof android.hardware.camera.provider@2.7-service-google)/maps' | grep camera
```
+212
View File
@@ -0,0 +1,212 @@
# CamSwapper System-Wide HAL Hook Testing Tutorial
This guide walks you through testing the system-wide camera HAL hook feature on a rooted Pixel 9a. This mode injects virtual camera feeds into all camera apps simultaneously via LD_PRELOAD, with no per-app Xposed scoping required.
## Prerequisites
- Rooted Pixel 9a (Magisk or KernelSU installed)
- ADB (Android Debug Bridge) set up on your computer
- `camswapper-hal-hook-v1.zip` — the flashable module ZIP (pre-built, in the repo)
- A test video file (MP4, H.264/H.265/VP9) or RTSP stream URL
## Step 1: Prepare the Device
1. Connect your Pixel 9a via USB
2. Enable USB debugging in Developer Options
3. Authorize the ADB connection on your device
4. Verify ADB connection:
```bash
adb devices
```
You should see your device serial with "device" status.
5. Verify root access:
```bash
adb shell su -c id
```
Should return `uid=0(root) gid=0(root) groups=0(root)`.
## Step 2: Install the Module (ZIP Flash)
The module is packaged as a standard ZIP file that can be flashed directly in Magisk or KernelSU.
### Option A: Flash via Magisk App
1. Transfer `camswapper-hal-hook-v1.zip` to your device
2. Open Magisk app → Modules tab → "Install from storage"
3. Select `camswapper-hal-hook-v1.zip`
4. Wait for installation to complete
5. Tap "Reboot"
### Option B: Flash via KernelSU Manager
1. Transfer `camswapper-hal-hook-v1.zip` to your device
2. Open KernelSU app → Modules tab → "+" button
3. Select `camswapper-hal-hook-v1.zip`
4. Wait for installation to complete
5. Tap "Reboot"
### Option C: Flash via Custom Recovery (TWRP)
1. Push the ZIP to your device: `adb push camswapper-hal-hook-v1.zip /sdcard/`
2. Boot into recovery
3. Flash the ZIP
4. Reboot system
### Verify Installation
After reboot, check the module is recognized:
```bash
adb shell su -c "ls -la /data/adb/modules/camera-hook/"
```
You should see `module.prop`, `post-fs-data.sh`, `service.sh`, `libcamera_hook.so`, `system.prop`, `sepolicy.rule`, and `customize.sh`.
## Step 3: Verify Module Installation
After the device reboots, run the included integration test script or verify manually.
### Option A: Run Integration Test Script
```bash
./test_hal_wrapper.sh
```
This script checks prerequisites, module installation, wrap property, config file, and hook loading status.
### Option B: Manual Verification
1. Check the wrap property is set correctly:
```bash
adb shell getprop wrap.android.hardware.camera.provider@2.7-service-google
```
Expected output: `LD_PRELOAD=/data/adb/modules/camera-hook/libcamera_hook.so`
2. Check the camera provider process is running:
```bash
adb shell pidof android.hardware.camera.provider@2.7-service-google
```
Should return a PID number.
3. Verify `libcamera_hook.so` is loaded in the provider process:
```bash
adb shell su -c "cat /proc/\$(pidof android.hardware.camera.provider@2.7-service-google)/maps | grep libcamera_hook"
```
Should show the path to `libcamera_hook.so`.
4. Check hook initialization logs:
```bash
adb logcat -d -s CameraHook
```
Should show hook initialization messages.
5. Check for SELinux denials:
```bash
adb shell su -c "dmesg | grep \"avc: denied\" | grep camera"
```
Should return empty if no denials are present.
## Step 4: Configure Video Source
The HAL hook reads configuration from `/data/local/camera_magic/config.txt`. You can configure it via the CamSwapper app or manually.
### Option A: Use CamSwapper App
1. Install the CamSwapper app on your device
2. Open the app and navigate to HAL Mode settings
3. Toggle "Enable HAL Mode"
4. Select source mode: File or RTSP
5. For File mode: select your video file (place it in `/data/local/camera_magic/video.mp4` or update config manually)
6. For RTSP mode: enter your RTSP stream URL
### Option B: Manual Config File
Create or edit the config file directly via adb:
```bash
adb shell su -c "mkdir -p /data/local/camera_magic"
adb shell su -c "echo 'enabled=1' > /data/local/camera_magic/config.txt"
adb shell su -c "echo 'source_mode=file' >> /data/local/camera_magic/config.txt"
adb shell su -c "echo 'video_path=/data/local/camera_magic/test_video.mp4' >> /data/local/camera_magic/config.txt"
adb shell su -c "echo 'rtsp_url=' >> /data/local/camera_magic/config.txt"
adb shell su -c "chmod 644 /data/local/camera_magic/config.txt"
```
#### Config File Format
```
enabled=1 # 0=off, 1=on
source_mode=file # file or rtsp
video_path=/data/local/camera_magic/video.mp4
rtsp_url=rtsp://192.168.1.100:554/stream
```
## Step 5: Test the Virtual Camera
1. Push your test video file to the device:
```bash
adb push test_video.mp4 /data/local/camera_magic/video.mp4
adb shell su -c "chmod 644 /data/local/camera_magic/video.mp4"
```
2. Open any camera app (Google Camera, Instagram, Telegram, etc.)
3. The camera preview should display your virtual video instead of the real camera feed.
4. Check hook logs for frame injection:
```bash
adb logcat -s CameraHook
```
Should show FPS counts and frame injection messages.
## Step 6: Test RTSP Stream (Optional)
1. Update config to RTSP mode:
```bash
adb shell su -c "sed -i 's/source_mode=file/source_mode=rtsp/' /data/local/camera_magic/config.txt"
adb shell su -c "sed -i 's|video_path=.*|rtsp_url=rtsp://YOUR_RTSP_URL|' /data/local/camera_magic/config.txt"
```
2. Restart the camera provider process to reload config:
```bash
adb shell su -c "killall android.hardware.camera.provider@2.7-service-google"
```
The process will restart automatically and load the new config.
3. Open a camera app to view the RTSP stream.
## Troubleshooting
### Hook Not Loading
- Verify module is in `/data/adb/modules/camera-hook/`
- Check wrap property is set correctly
- Reboot the device
- Check `adb shell dmesg | grep CameraHook` for error messages
### No Virtual Feed Showing
- Verify `enabled=1` in config file
- Check video file path is correct and accessible
- Test RTSP URL with VLC first to ensure it's reachable
- View hook logs: `adb logcat -s CameraHook`
- Verify `libcamera_hook.so` is loaded in the provider process
### SELinux Denials
- Check `adb shell dmesg | grep "avc: denied"`
- Ensure `sepolicy.rule` is present in the module directory
- Temporary test: set SELinux to Permissive with `adb shell su -c setenforce 0`
### Camera App Crashes
- Check logcat for crashes: `adb logcat -d | grep -i crash`
- Verify video format is supported (H.264/H.265/VP9)
- Try a lower resolution/bitrate video file
## Uninstall/Disable HAL Hook
### Temporary Disable
Set `enabled=0` in config file:
```bash
adb shell su -c "sed -i 's/enabled=1/enabled=0/' /data/local/camera_magic/config.txt"
```
Restart camera provider: `adb shell su -c "killall android.hardware.camera.provider@2.7-service-google"`
### Permanent Uninstall
```bash
adb shell su -c "rm -rf /data/adb/modules/camera-hook"
adb shell su -c "setprop wrap.android.hardware.camera.provider@2.7-service-google ''"
adb shell su -c "rm -rf /data/local/camera_magic"
adb reboot
```
---
+27
View File
@@ -0,0 +1,27 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are part of the Android SDK,
# and which are part of AndroidX external libraries.
android.useAndroidX=true
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true
# Force OpenJDK and disable auto-download
org.gradle.java.installations.auto-download=false
org.gradle.java.installations.auto-detect=true
org.gradle.java.home=/nix/store/mfg02prkjhwv8z6z36b953h8k34dcfwc-openjdk-17.0.18+8
kotlin.jvm.toolchain.enabled=false
+37
View File
@@ -0,0 +1,37 @@
[versions]
agp = "8.13.1"
kotlin = "2.1.0"
coreKtx = "1.17.0"
junit = "4.13.2"
junitVersion = "1.3.0"
espressoCore = "3.7.0"
lifecycleRuntimeKtx = "2.10.0"
activityCompose = "1.12.0"
composeBom = "2024.09.00"
foundation = "1.9.5"
adaptive = "1.2.0"
material3 = "1.4.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation", version.ref = "foundation" }
androidx-compose-adaptive = { group = "androidx.compose.material3.adaptive", name = "adaptive", version.ref = "adaptive" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
BIN
View File
Binary file not shown.
+8
View File
@@ -0,0 +1,8 @@
#Tue Dec 02 15:51:11 CST 2025
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+252
View File
@@ -0,0 +1,252 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
Vendored Executable
+95
View File
@@ -0,0 +1,95 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+1
View File
@@ -0,0 +1 @@
build/
+57
View File
@@ -0,0 +1,57 @@
cmake_minimum_required(VERSION 3.22.1)
project(camera_hook LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
# Find Android libraries
find_library(log-lib log)
find_library(dl-lib dl)
find_library(mediandk-lib mediandk)
find_library(android-lib android)
# HAL hook shared library
add_library(camera_hook SHARED
src/camera_wrapper.cpp
src/video_decoder.cpp
src/rtsp_client.cpp
src/buffer_converter.cpp
)
target_include_directories(camera_hook PRIVATE
${CMAKE_SOURCE_DIR}/include
)
target_link_libraries(camera_hook
${log-lib}
${dl-lib}
${mediandk-lib}
${android-lib}
)
# Compiler flags for LD_PRELOAD hook
target_compile_options(camera_hook PRIVATE
-Wall
-Wextra
-fvisibility=hidden
-fPIC
)
# Enable NEON intrinsics for ARM64
if(ANDROID_ABI STREQUAL "arm64-v8a")
target_compile_options(camera_hook PRIVATE -march=armv8-a+simd)
endif()
# Output name
set_target_properties(camera_hook PROPERTIES
OUTPUT_NAME "camera_hook"
LIBRARY_OUTPUT_DIRECTORY "${CMAKE_SOURCE_DIR}/../app/src/main/jniLibs/${ANDROID_ABI}"
)
# Camera provider wrapper (static C binary, bind-mounted over original)
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
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_SOURCE_DIR}/../root-module"
)
+72
View File
@@ -0,0 +1,72 @@
#ifndef BUFFER_CONVERTER_H
#define BUFFER_CONVERTER_H
#include <cstdint>
#include <cstddef>
// Buffer format conversion utilities for HAL compatibility.
// Converts between decoder output formats and HAL-expected formats.
// Uses ARM NEON intrinsics for performance on Pixel 9a (arm64-v8a).
namespace buffer_converter {
// Convert YUV420 planar (I420) to NV21 (semi-planar, VU interleaved)
// YUV420 planar: Y plane, U plane, V plane (separate)
// NV21: Y plane, VU interleaved plane
//
// src_y, src_u, src_v: source plane pointers
// src_y_stride, src_uv_stride: source plane strides (bytes per row)
// dst_nv21: destination buffer (Y plane followed by VU interleaved)
// dst_y_stride, dst_uv_stride: destination strides
// width, height: frame dimensions
//
// Returns true on success, false on error
bool yuv420_planar_to_nv21(
const uint8_t* src_y, int src_y_stride,
const uint8_t* src_u, int src_u_stride,
const uint8_t* src_v, int src_v_stride,
uint8_t* dst_nv21, int dst_y_stride, int dst_uv_stride,
int width, int height);
// Convert YUV420 planar (I420) to YUV420 planar with different stride
// Handles stride mismatch between decoder output and HAL buffer.
// Copies Y plane with stride adjustment, then U and V planes.
//
// Returns true on success
bool yuv420_planar_copy_with_stride(
const uint8_t* src_y, int src_y_stride,
const uint8_t* src_u, int src_u_stride,
const uint8_t* src_v, int src_v_stride,
uint8_t* dst_y, int dst_y_stride,
uint8_t* dst_u, int dst_u_stride,
uint8_t* dst_v, int dst_v_stride,
int width, int height);
// Convert NV21 (semi-planar) to YUV420 planar (I420)
// Used when decoder outputs NV21 but HAL expects planar.
//
// Returns true on success
bool nv21_to_yuv420_planar(
const uint8_t* src_nv21, int src_y_stride, int src_uv_stride,
uint8_t* dst_y, int dst_y_stride,
uint8_t* dst_u, int dst_u_stride,
uint8_t* dst_v, int dst_v_stride,
int width, int height);
// Fast Y plane copy with NEON (handles stride mismatch)
// Copies width bytes per row, advancing by stride each row.
void copy_plane_neon(
const uint8_t* src, int src_stride,
uint8_t* dst, int dst_stride,
int width, int height);
// Interleave U and V planes into NV21 format (VU order) using NEON
void interleave_uv_to_nv21_neon(
const uint8_t* src_u, int src_u_stride,
const uint8_t* src_v, int src_v_stride,
uint8_t* dst_vu, int dst_vu_stride,
int width, int height);
} // namespace buffer_converter
#endif // BUFFER_CONVERTER_H
+34
View File
@@ -0,0 +1,34 @@
#ifndef CAMERA_HAL_ICAMERA_DEVICE_H
#define CAMERA_HAL_ICAMERA_DEVICE_H
#include "types.h"
#include <cstdint>
namespace camera_hal {
// ICameraDevice AIDL v4 interface
// Mirrors: hardware/interfaces/camera/device/aidl/android/hardware/camera/device/ICameraDevice.aidl
class ICameraDevice {
public:
virtual ~ICameraDevice() = default;
virtual int32_t open(void* callback) = 0;
virtual int32_t openInjectionSession(void* callback) = 0;
virtual int32_t setTorchMode(bool enabled) = 0;
virtual int32_t dumpState(int32_t fd) = 0;
virtual int32_t getCameraCharacteristics(void* characteristics) = 0;
virtual int32_t getPhysicalCameraCharacteristics(
const char* physical_camera_id,
void* characteristics) = 0;
virtual int32_t close() = 0;
};
// Function pointer types for dlsym
typedef int32_t (*ICameraDevice_open_t)(void* self, void* callback);
typedef int32_t (*ICameraDevice_close_t)(void* self);
typedef int32_t (*ICameraDevice_getCameraCharacteristics_t)(
void* self, void* characteristics);
} // namespace camera_hal
#endif // CAMERA_HAL_ICAMERA_DEVICE_H
@@ -0,0 +1,60 @@
#ifndef CAMERA_HAL_ICAMERA_DEVICE_SESSION_H
#define CAMERA_HAL_ICAMERA_DEVICE_SESSION_H
#include "types.h"
#include <cstdint>
namespace camera_hal {
// ICameraDeviceSession AIDL v4 interface
// Mirrors: hardware/interfaces/camera/device/aidl/android/hardware/camera/device/ICameraDeviceSession.aidl
class ICameraDeviceSession {
public:
virtual ~ICameraDeviceSession() = default;
virtual int32_t configureStreams(
const Stream* streams,
int32_t stream_count,
StreamConfigurationMode mode,
HalStreamConfiguration* out_config) = 0;
virtual int32_t processCaptureRequest(
const CaptureRequest* requests,
int32_t request_count,
int32_t* out_num_request_processed) = 0;
virtual int32_t flush() = 0;
virtual int32_t close() = 0;
virtual int32_t signalStreamFlush(const int32_t* stream_ids, int32_t count) = 0;
virtual int32_t getCaptureRequestMetadataQueue(void* queue) = 0;
virtual int32_t getCaptureResultMetadataQueue(void* queue) = 0;
virtual int32_t switchToOffline(
const int32_t* streams_to_keep,
int32_t count,
void* out_offline_session) = 0;
virtual int32_t isReconfigurationRequired(
const void* old_session_params,
const void* new_session_params,
bool* out_required) = 0;
};
// Function pointer types for dlsym
typedef int32_t (*ICameraDeviceSession_configureStreams_t)(
void* self,
const Stream* streams,
int32_t stream_count,
StreamConfigurationMode mode,
HalStreamConfiguration* out_config);
typedef int32_t (*ICameraDeviceSession_processCaptureRequest_t)(
void* self,
const CaptureRequest* requests,
int32_t request_count,
int32_t* out_num_request_processed);
typedef int32_t (*ICameraDeviceSession_flush_t)(void* self);
typedef int32_t (*ICameraDeviceSession_close_t)(void* self);
} // namespace camera_hal
#endif // CAMERA_HAL_ICAMERA_DEVICE_SESSION_H
@@ -0,0 +1,42 @@
#ifndef CAMERA_HAL_ICAMERA_PROVIDER_H
#define CAMERA_HAL_ICAMERA_PROVIDER_H
#include "types.h"
#include <cstdint>
namespace camera_hal {
// ICameraProvider AIDL v3 interface
// Mirrors: hardware/interfaces/camera/provider/aidl/android/hardware/camera/provider/ICameraProvider.aidl
class ICameraProvider {
public:
virtual ~ICameraProvider() = default;
virtual int32_t setCallback(void* callback) = 0;
virtual int32_t getCameraIdList(char*** camera_ids, int32_t* count) = 0;
virtual int32_t getCameraDeviceInterface(
const char* camera_id,
void** out_device) = 0;
virtual int32_t notifyDeviceStateChange(int64_t device_state) = 0;
virtual int32_t getConcurrentStreamingCameraIds(
char*** camera_ids, int32_t* count) = 0;
virtual int32_t openSession(
const char* camera_id,
void* callback,
void** out_session) = 0;
virtual int32_t getVendorTags(void* tags) = 0;
virtual int32_t getCameraCharacteristics(
const char* camera_id,
void* characteristics) = 0;
};
typedef int32_t (*ICameraProvider_getCameraIdList_t)(
void* self, char*** camera_ids, int32_t* count);
typedef int32_t (*ICameraProvider_getCameraDeviceInterface_t)(
void* self, const char* camera_id, void** out_device);
typedef int32_t (*ICameraProvider_openSession_t)(
void* self, const char* camera_id, void* callback, void** out_session);
} // namespace camera_hal
#endif // CAMERA_HAL_ICAMERA_PROVIDER_H
+103
View File
@@ -0,0 +1,103 @@
#ifndef CAMERA_HAL_TYPES_H
#define CAMERA_HAL_TYPES_H
#include <cstdint>
#include <cstddef>
// AIDL Camera HAL v4 common types
// These mirror the AIDL interface types from hardware/interfaces/camera/
namespace camera_hal {
// Stream format constants (matching AIDL CameraMetadata)
enum StreamFormat : int32_t {
FORMAT_YUV_420_888 = 32,
FORMAT_IMPLEMENTATION_DEFINED = 35,
FORMAT_BLOB_JPEG = 37,
FORMAT_RAW16 = 38,
FORMAT_RAW_PRIVATE = 39,
FORMAT_RAW10 = 40,
FORMAT_RAW12 = 41,
FORMAT_DEPTH16 = 42,
FORMAT_DEPTH_POINT_CLOUD = 43,
FORMAT_PRIVATE = 50,
};
// Stream direction
enum StreamDirection : int32_t {
STREAM_OUTPUT = 0,
STREAM_INPUT = 1,
};
// Stream configuration
struct Stream {
int32_t id;
int32_t width;
int32_t height;
StreamFormat format;
StreamDirection direction;
int32_t usage;
int32_t rotation;
int32_t data_space;
void* physical_camera_id; // nullable
};
// Buffer status
enum BufferStatus : int32_t {
BUFFER_STATUS_OK = 0,
BUFFER_STATUS_ERROR = 1,
BUFFER_STATUS_NO_BUFFER = 2,
};
// Camera buffer descriptor (wraps AHardwareBuffer / GraphicBuffer)
struct CameraBuffer {
int32_t stream_id;
int64_t buffer_id;
void* handle; // AHardwareBuffer* or native_handle_t*
int32_t status;
int64_t timestamp;
void* acquire_fence;
void* release_fence;
};
// Capture request
struct CaptureRequest {
int32_t frame_number;
int32_t settings_count;
void* settings; // CameraMetadata*
int32_t input_buffer_present;
CameraBuffer* input_buffer;
int32_t output_buffer_count;
CameraBuffer* output_buffers;
int32_t physical_camera_id_count;
void* physical_camera_ids;
void* physical_camera_settings;
};
// Capture result
struct CaptureResult {
int32_t frame_number;
void* result; // CameraMetadata*
int32_t output_buffer_count;
CameraBuffer* output_buffers;
int32_t physical_camera_metadata_count;
void* physical_camera_ids;
void* physical_camera_metadata;
};
// Stream configuration mode
enum StreamConfigurationMode : int32_t {
NORMAL_MODE = 0,
CONSTRAINED_HIGH_SPEED_MODE = 1,
};
// HalStreamConfiguration (result of configureStreams)
struct HalStreamConfiguration {
int32_t stream_count;
Stream* streams;
StreamConfigurationMode mode;
};
} // namespace camera_hal
#endif // CAMERA_HAL_TYPES_H
+59
View File
@@ -0,0 +1,59 @@
#ifndef RTSP_CLIENT_H
#define RTSP_CLIENT_H
#include <cstdint>
#include <cstddef>
#include <string>
namespace rtsp_client {
static constexpr int RTSP_DEFAULT_PORT = 554;
static constexpr int RTP_BUFFER_SIZE = 65536;
static constexpr int MAX_URL_LEN = 512;
struct RtspState {
std::string url;
std::string host;
int port;
std::string path;
int rtsp_fd;
int rtp_fd;
int rtcp_fd;
int local_rtp_port;
int local_rtcp_port;
std::string session_id;
std::string control_url;
std::string video_track_url;
bool connected;
bool running;
bool playing;
pthread_t receiver_thread;
int shmem_fd;
uint8_t* shmem_base;
size_t shmem_size;
int32_t width;
int32_t height;
uint32_t frame_count;
uint64_t bytes_received;
};
int init_rtsp(const char* url);
int connect_rtsp();
int play_rtsp();
void stop_rtsp();
void release_rtsp();
bool is_rtsp_connected();
int get_rtsp_shmem_fd();
int get_rtsp_width();
int get_rtsp_height();
} // namespace rtsp_client
#endif // RTSP_CLIENT_H
+100
View File
@@ -0,0 +1,100 @@
#ifndef VIDEO_DECODER_H
#define VIDEO_DECODER_H
#include <cstdint>
#include <cstddef>
#include <string>
// Video decoder using NDK MediaCodec API
// Decodes video files to YUV420 frames and writes to shared memory ring buffer
// for consumption by the HAL hook in the camera provider process.
namespace video_decoder {
// Shared memory ring buffer header
// This structure is placed at the beginning of the memfd shared memory region.
struct RingBufferHeader {
uint32_t magic; // 0xCAM2MAGC
uint32_t version; // Header version (1)
uint32_t frame_count; // Number of frame slots in ring buffer
uint32_t frame_width; // Decoded frame width
uint32_t frame_height; // Decoded frame height
uint32_t frame_size; // Size of one YUV420 frame (w * h * 3/2)
uint32_t write_index; // Atomic: next slot to write
uint32_t read_index; // Atomic: last slot read by consumer
uint32_t sequence; // Monotonically increasing frame counter
uint32_t flags; // Bit flags (bit 0: decoder running)
uint64_t last_timestamp; // Timestamp of last written frame (us)
uint8_t reserved[216]; // Padding to 256 bytes
};
static constexpr uint32_t RING_MAGIC = 0xCA22A61C;
static constexpr uint32_t RING_VERSION = 1;
static constexpr uint32_t RING_FRAME_COUNT = 4;
static constexpr uint32_t RING_HEADER_SIZE = 256;
static constexpr uint32_t FLAG_DECODER_RUNNING = 0x1;
// Calculate total shared memory size needed
inline size_t calc_shmem_size(uint32_t width, uint32_t height, uint32_t frame_count = RING_FRAME_COUNT) {
size_t frame_size = (size_t)width * height * 3 / 2; // YUV420
return RING_HEADER_SIZE + (frame_size * frame_count);
}
// Get pointer to frame slot in shared memory
inline uint8_t* get_frame_ptr(uint8_t* base, uint32_t index, uint32_t frame_size, uint32_t frame_count) {
return base + RING_HEADER_SIZE + ((index % frame_count) * frame_size);
}
// Decoder state
struct DecoderState {
std::string video_path;
int32_t width;
int32_t height;
int32_t rotation; // 0, 90, 180, 270
bool loop;
bool running;
// Shared memory
int shmem_fd;
uint8_t* shmem_base;
size_t shmem_size;
RingBufferHeader* header;
// Decoder thread
pthread_t decoder_thread;
};
// Initialize the video decoder
// Returns 0 on success, negative on error
int init_decoder(const char* video_path, bool loop = true);
// Start the decoder thread
// Returns 0 on success
int start_decoder();
// Stop the decoder thread
void stop_decoder();
// Release decoder resources
void release_decoder();
// Get the shared memory fd (for passing to other processes)
// Returns -1 if not initialized
int get_shmem_fd();
// Get decoder state info
int get_decoder_width();
int get_decoder_height();
bool is_decoder_running();
// Write a YUV420 frame to the shared memory ring buffer
// Called internally by the decoder thread
// y_data, u_data, v_data: pointers to Y, U, V planes
// y_stride, uv_stride: stride of Y and UV planes
// width, height: frame dimensions
bool write_frame_to_ring(const uint8_t* y_data, const uint8_t* u_data, const uint8_t* v_data,
int y_stride, int uv_stride, int width, int height);
} // namespace video_decoder
#endif // VIDEO_DECODER_H
+173
View File
@@ -0,0 +1,173 @@
#include "buffer_converter.h"
#ifdef __aarch64__
#include <arm_neon.h>
#endif
#include <cstring>
#include <algorithm>
namespace buffer_converter {
void copy_plane_neon(
const uint8_t* src, int src_stride,
uint8_t* dst, int dst_stride,
int width, int height) {
#ifdef __aarch64__
for (int row = 0; row < height; row++) {
const uint8_t* src_row = src + row * src_stride;
uint8_t* dst_row = dst + row * dst_stride;
int col = 0;
for (; col + 15 < width; col += 16) {
uint8x16_t data = vld1q_u8(src_row + col);
vst1q_u8(dst_row + col, data);
}
for (; col < width; col++) {
dst_row[col] = src_row[col];
}
}
#else
for (int row = 0; row < height; row++) {
std::memcpy(dst + row * dst_stride, src + row * src_stride, width);
}
#endif
}
void interleave_uv_to_nv21_neon(
const uint8_t* src_u, int src_u_stride,
const uint8_t* src_v, int src_v_stride,
uint8_t* dst_vu, int dst_vu_stride,
int width, int height) {
int uv_width = width / 2;
int uv_height = height / 2;
#ifdef __aarch64__
for (int row = 0; row < uv_height; row++) {
const uint8_t* u_row = src_u + row * src_u_stride;
const uint8_t* v_row = src_v + row * src_v_stride;
uint8_t* dst_row = dst_vu + row * dst_vu_stride;
int col = 0;
for (; col + 15 < uv_width; col += 16) {
uint8x16_t u_data = vld1q_u8(u_row + col);
uint8x16_t v_data = vld1q_u8(v_row + col);
uint8x16x2_t vu_interleaved;
vu_interleaved.val[0] = v_data;
vu_interleaved.val[1] = u_data;
vst2q_u8(dst_row + col * 2, vu_interleaved);
}
for (; col < uv_width; col++) {
dst_row[col * 2] = v_row[col];
dst_row[col * 2 + 1] = u_row[col];
}
}
#else
for (int row = 0; row < uv_height; row++) {
const uint8_t* u_row = src_u + row * src_u_stride;
const uint8_t* v_row = src_v + row * src_v_stride;
uint8_t* dst_row = dst_vu + row * dst_vu_stride;
for (int col = 0; col < uv_width; col++) {
dst_row[col * 2] = v_row[col];
dst_row[col * 2 + 1] = u_row[col];
}
}
#endif
}
bool yuv420_planar_to_nv21(
const uint8_t* src_y, int src_y_stride,
const uint8_t* src_u, int src_u_stride,
const uint8_t* src_v, int src_v_stride,
uint8_t* dst_nv21, int dst_y_stride, int dst_uv_stride,
int width, int height) {
if (!src_y || !src_u || !src_v || !dst_nv21 || width <= 0 || height <= 0) {
return false;
}
copy_plane_neon(src_y, src_y_stride, dst_nv21, dst_y_stride, width, height);
uint8_t* dst_vu = dst_nv21 + (dst_y_stride * height);
interleave_uv_to_nv21_neon(src_u, src_u_stride, src_v, src_v_stride,
dst_vu, dst_uv_stride, width, height);
return true;
}
bool yuv420_planar_copy_with_stride(
const uint8_t* src_y, int src_y_stride,
const uint8_t* src_u, int src_u_stride,
const uint8_t* src_v, int src_v_stride,
uint8_t* dst_y, int dst_y_stride,
uint8_t* dst_u, int dst_u_stride,
uint8_t* dst_v, int dst_v_stride,
int width, int height) {
if (!src_y || !src_u || !src_v || !dst_y || !dst_u || !dst_v ||
width <= 0 || height <= 0) {
return false;
}
copy_plane_neon(src_y, src_y_stride, dst_y, dst_y_stride, width, height);
int uv_width = width / 2;
int uv_height = height / 2;
copy_plane_neon(src_u, src_u_stride, dst_u, dst_u_stride, uv_width, uv_height);
copy_plane_neon(src_v, src_v_stride, dst_v, dst_v_stride, uv_width, uv_height);
return true;
}
bool nv21_to_yuv420_planar(
const uint8_t* src_nv21, int src_y_stride, int src_uv_stride,
uint8_t* dst_y, int dst_y_stride,
uint8_t* dst_u, int dst_u_stride,
uint8_t* dst_v, int dst_v_stride,
int width, int height) {
if (!src_nv21 || !dst_y || !dst_u || !dst_v || width <= 0 || height <= 0) {
return false;
}
copy_plane_neon(src_nv21, src_y_stride, dst_y, dst_y_stride, width, height);
int uv_width = width / 2;
int uv_height = height / 2;
const uint8_t* src_vu = src_nv21 + (src_y_stride * height);
#ifdef __aarch64__
for (int row = 0; row < uv_height; row++) {
const uint8_t* vu_row = src_vu + row * src_uv_stride;
uint8_t* u_row = dst_u + row * dst_u_stride;
uint8_t* v_row = dst_v + row * dst_v_stride;
int col = 0;
for (; col + 15 < uv_width; col += 16) {
uint8x16x2_t vu = vld2q_u8(vu_row + col * 2);
vst1q_u8(v_row + col, vu.val[0]);
vst1q_u8(u_row + col, vu.val[1]);
}
for (; col < uv_width; col++) {
v_row[col] = vu_row[col * 2];
u_row[col] = vu_row[col * 2 + 1];
}
}
#else
for (int row = 0; row < uv_height; row++) {
const uint8_t* vu_row = src_vu + row * src_uv_stride;
uint8_t* u_row = dst_u + row * dst_u_stride;
uint8_t* v_row = dst_v + row * dst_v_stride;
for (int col = 0; col < uv_width; col++) {
v_row[col] = vu_row[col * 2];
u_row[col] = vu_row[col * 2 + 1];
}
}
#endif
return true;
}
} // namespace buffer_converter
+15
View File
@@ -0,0 +1,15 @@
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <stdio.h>
#include <errno.h>
#define REAL_PROVIDER "/data/adb/modules/camera-hook/camera-provider-real"
#define HOOK_LIB "/data/adb/modules/camera-hook/libcamera_hook.so"
int main(int argc, char *argv[]) {
setenv("LD_PRELOAD", HOOK_LIB, 1);
execv(REAL_PROVIDER, argv);
fprintf(stderr, "CameraHook: execv(%s) failed: %s\n", REAL_PROVIDER, strerror(errno));
return 1;
}
+386
View File
@@ -0,0 +1,386 @@
#include <dlfcn.h>
#include <android/log.h>
#include <unistd.h>
#include <cstring>
#include <cstdio>
#include <map>
#include <string>
#include <vector>
#include <mutex>
#include <atomic>
#include <chrono>
#include <fstream>
#include "camera_hal/types.h"
#include "camera_hal/ICameraProvider.h"
#include "camera_hal/ICameraDevice.h"
#include "camera_hal/ICameraDeviceSession.h"
#define LOG_TAG "CameraHook"
#define ALOGD(...) __android_log_print(ANDROID_LOG_DEBUG, LOG_TAG, __VA_ARGS__)
#define ALOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
#define ALOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
#define ALOGW(...) __android_log_print(ANDROID_LOG_WARN, LOG_TAG, __VA_ARGS__)
static void* g_real_hal_handle = nullptr;
static bool g_hook_initialized = false;
static const char* APEX_HAL_PATH =
"/apex/com.google.pixel.camera.hal/lib64/libgooglecamerahal.so";
struct StreamInfo {
int32_t id;
int32_t width;
int32_t height;
int32_t format;
int32_t direction;
int32_t usage;
int32_t data_space;
int32_t hal_format;
int32_t stride;
};
struct HookState {
std::map<std::string, void*> open_devices;
std::map<void*, void*> device_sessions;
bool virtual_camera_enabled = false;
int32_t target_stream_format = camera_hal::FORMAT_YUV_420_888;
std::mutex stream_mutex;
std::map<int32_t, StreamInfo> stream_registry;
int32_t yuv_preview_stream_id = -1;
int32_t yuv_preview_width = 0;
int32_t yuv_preview_height = 0;
std::atomic<int64_t> frame_count{0};
std::atomic<int64_t> injected_count{0};
std::chrono::steady_clock::time_point fps_start;
std::atomic<int64_t> fps_frame_count{0};
static constexpr const char* CONFIG_PATH = "/data/local/camera_magic/config.txt";
};
static HookState g_hook_state;
static bool load_real_hal() {
if (g_real_hal_handle) {
return true;
}
g_real_hal_handle = dlopen(APEX_HAL_PATH, RTLD_NOW | RTLD_LOCAL);
if (!g_real_hal_handle) {
ALOGE("Failed to load real HAL: %s", dlerror());
return false;
}
ALOGI("Loaded real HAL from %s", APEX_HAL_PATH);
return true;
}
static void* resolve_real_symbol(const char* symbol) {
if (!g_real_hal_handle) {
return nullptr;
}
void* addr = dlsym(g_real_hal_handle, symbol);
if (!addr) {
ALOGW("Symbol not found in real HAL: %s", symbol);
}
return addr;
}
static bool check_virtual_camera_enabled() {
std::ifstream config(g_hook_state.CONFIG_PATH);
if (!config.is_open()) {
return false;
}
std::string line;
while (std::getline(config, line)) {
if (line.find("virtual_camera=1") != std::string::npos ||
line.find("enabled=true") != std::string::npos) {
return true;
}
}
return false;
}
static bool inject_video_frame(int32_t stream_id, void* buffer_handle,
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)buffer_handle;
(void)width;
(void)height;
return false;
}
static void update_fps_counter() {
auto now = std::chrono::steady_clock::now();
auto elapsed = std::chrono::duration_cast<std::chrono::seconds>(
now - g_hook_state.fps_start).count();
if (elapsed >= 1) {
int64_t frames = g_hook_state.fps_frame_count.exchange(0);
if (frames > 0) {
ALOGI("[FPS] %.1f fps (%lld frames in %llds)",
(double)frames / elapsed, (long long)frames, (long long)elapsed);
}
g_hook_state.fps_start = now;
}
}
class CameraDeviceSessionHook {
public:
void* real_session;
explicit CameraDeviceSessionHook(void* real)
: real_session(real) {
ALOGI("[SessionHook] Created for session %p", real);
}
int32_t configureStreams(
const camera_hal::Stream* streams,
int32_t stream_count,
camera_hal::StreamConfigurationMode mode,
camera_hal::HalStreamConfiguration* out_config) {
ALOGI("[SessionHook] configureStreams: %d streams, mode=%d",
stream_count, mode);
{
std::lock_guard<std::mutex> lock(g_hook_state.stream_mutex);
g_hook_state.stream_registry.clear();
g_hook_state.yuv_preview_stream_id = -1;
for (int32_t i = 0; i < stream_count; i++) {
const auto& s = streams[i];
StreamInfo info;
info.id = s.id;
info.width = s.width;
info.height = s.height;
info.format = s.format;
info.direction = s.direction;
info.usage = s.usage;
info.data_space = s.data_space;
info.hal_format = 0;
info.stride = 0;
g_hook_state.stream_registry[s.id] = info;
ALOGI("[SessionHook] Stream[%d]: id=%d %dx%d fmt=%d dir=%d usage=0x%x",
i, s.id, s.width, s.height, s.format, s.direction, s.usage);
if (s.format == camera_hal::FORMAT_YUV_420_888 &&
s.direction == camera_hal::STREAM_OUTPUT &&
g_hook_state.yuv_preview_stream_id == -1) {
g_hook_state.yuv_preview_stream_id = s.id;
g_hook_state.yuv_preview_width = s.width;
g_hook_state.yuv_preview_height = s.height;
ALOGI("[SessionHook] -> YUV preview stream identified (id=%d)", s.id);
}
}
}
ALOGI("[SessionHook] Stream registry: %zu streams, YUV preview id=%d",
g_hook_state.stream_registry.size(),
g_hook_state.yuv_preview_stream_id);
return 0;
}
int32_t processCaptureRequest(
const camera_hal::CaptureRequest* requests,
int32_t request_count,
int32_t* out_num_request_processed) {
int64_t total = g_hook_state.frame_count.fetch_add(request_count) + request_count;
bool enabled = check_virtual_camera_enabled();
if (enabled != g_hook_state.virtual_camera_enabled) {
g_hook_state.virtual_camera_enabled = enabled;
ALOGI("[SessionHook] Virtual camera %s", enabled ? "ENABLED" : "DISABLED");
}
for (int32_t r = 0; r < request_count; r++) {
const auto& req = requests[r];
for (int32_t b = 0; b < req.output_buffer_count; b++) {
const auto& buf = req.output_buffers[b];
int32_t sid = buf.stream_id;
if (enabled && sid == g_hook_state.yuv_preview_stream_id) {
StreamInfo* info = nullptr;
{
std::lock_guard<std::mutex> lock(g_hook_state.stream_mutex);
auto it = g_hook_state.stream_registry.find(sid);
if (it != g_hook_state.stream_registry.end()) {
info = &it->second;
}
}
if (info && buf.handle) {
bool injected = inject_video_frame(
sid, buf.handle, info->width, info->height);
if (injected) {
g_hook_state.injected_count.fetch_add(1);
g_hook_state.fps_frame_count.fetch_add(1);
update_fps_counter();
}
}
}
}
if (total % 300 == 0) {
ALOGI("[SessionHook] Frames: %lld total, %lld injected",
(long long)total,
(long long)g_hook_state.injected_count.load());
}
}
if (out_num_request_processed) {
*out_num_request_processed = request_count;
}
return 0;
}
int32_t flush() {
ALOGI("[SessionHook] flush called");
return 0;
}
int32_t close() {
ALOGI("[SessionHook] close called");
return 0;
}
};
class CameraDeviceHook {
public:
void* real_device;
std::string camera_id;
CameraDeviceHook(void* real, const char* id)
: real_device(real), camera_id(id ? id : "unknown") {
ALOGI("[DeviceHook] Created for device '%s' (%p)", camera_id.c_str(), real);
}
int32_t open(void* callback) {
ALOGI("[DeviceHook] open called for '%s'", camera_id.c_str());
return 0;
}
int32_t getCameraCharacteristics(void* characteristics) {
ALOGI("[DeviceHook] getCameraCharacteristics for '%s'", camera_id.c_str());
return 0;
}
int32_t close() {
ALOGI("[DeviceHook] close called for '%s'", camera_id.c_str());
g_hook_state.open_devices.erase(camera_id);
return 0;
}
};
class CameraProviderHook {
public:
void* real_provider;
explicit CameraProviderHook(void* real)
: real_provider(real) {
ALOGI("[ProviderHook] Created for provider %p", real);
}
int32_t getCameraIdList(char*** camera_ids, int32_t* count) {
ALOGI("[ProviderHook] getCameraIdList called");
return 0;
}
int32_t getCameraDeviceInterface(
const char* camera_id,
void** out_device) {
ALOGI("[ProviderHook] getCameraDeviceInterface: '%s'", camera_id);
return 0;
}
int32_t openSession(
const char* camera_id,
void* callback,
void** out_session) {
ALOGI("[ProviderHook] openSession: '%s'", camera_id);
return 0;
}
};
__attribute__((constructor))
static void camera_hook_init() {
if (g_hook_initialized) {
return;
}
ALOGI("=== CameraHook LD_PRELOAD library loaded ===");
ALOGI("PID: %d, Process: camera provider", getpid());
if (!load_real_hal()) {
ALOGE("Cannot load real HAL, hook will not function");
return;
}
g_hook_initialized = true;
ALOGI("CameraHook initialized successfully");
}
__attribute__((destructor))
static void camera_hook_deinit() {
if (g_real_hal_handle) {
dlclose(g_real_hal_handle);
g_real_hal_handle = nullptr;
}
ALOGI("CameraHook unloaded");
}
extern "C" {
__attribute__((visibility("default")))
void* dlopen(const char* filename, int flags) {
typedef void* (*real_dlopen_t)(const char*, int);
static real_dlopen_t real_dlopen = nullptr;
if (!real_dlopen) {
real_dlopen = (real_dlopen_t)::dlsym(RTLD_NEXT, "dlopen");
}
if (filename && strstr(filename, "libgooglecamerahal.so")) {
ALOGI("[dlopen] Intercepted: %s", filename);
if (!load_real_hal()) {
return nullptr;
}
return g_real_hal_handle;
}
return real_dlopen(filename, flags);
}
__attribute__((visibility("default")))
void* dlsym(void* handle, const char* symbol) {
typedef void* (*real_dlsym_t)(void*, const char*);
static real_dlsym_t real_dlsym = nullptr;
if (!real_dlsym) {
real_dlsym = (real_dlsym_t)::dlsym(RTLD_NEXT, "dlsym");
}
void* result = real_dlsym(handle, symbol);
if (result && g_hook_initialized && symbol) {
if (strstr(symbol, "CameraProvider") ||
strstr(symbol, "CameraDevice") ||
strstr(symbol, "createProvider") ||
strstr(symbol, "getProvider")) {
ALOGI("[dlsym] HAL symbol intercepted: %s -> %p", symbol, result);
}
}
return result;
}
} // extern "C"
+15
View File
@@ -0,0 +1,15 @@
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <stdio.h>
#include <errno.h>
#define REAL_CAMERASERVER "/data/adb/modules/camera-hook/cameraserver-real"
#define HOOK_LIB "/data/local/camera_magic/libcamera_hook.so"
int main(int argc, char *argv[]) {
setenv("LD_PRELOAD", HOOK_LIB, 1);
execv(REAL_CAMERASERVER, argv);
fprintf(stderr, "CameraHook: execv(%s) failed: %s\n", REAL_CAMERASERVER, strerror(errno));
return 1;
}
+173
View File
@@ -0,0 +1,173 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/ptrace.h>
#include <sys/wait.h>
#include <sys/user.h>
#include <sys/uio.h>
#include <elf.h>
#include <errno.h>
#define LIB_PATH "/data/local/tmp/libcamera_hook.so"
#define ARM64_BRK 0xd4200000
static int ptrace_write_data(long pid, unsigned long addr, const void *buf, size_t len) {
for (size_t i = 0; i < len; i += sizeof(long)) {
long val = 0;
size_t cpy = (len - i < sizeof(long)) ? len - i : sizeof(long);
memcpy(&val, (const char*)buf + i, cpy);
if (ptrace(PTRACE_POKETEXT, pid, addr + i, val) < 0) {
fprintf(stderr, "POKETEXT failed at 0x%lx\n", addr + i);
return -1;
}
}
return 0;
}
static unsigned long find_linker_rx_base(long pid, unsigned long *file_offset_out) {
char path[256];
snprintf(path, sizeof(path), "/proc/%ld/maps", pid);
FILE *f = fopen(path, "r");
if (!f) { perror("fopen maps"); return 0; }
char line[1024];
while (fgets(line, sizeof(line), f)) {
if (strstr(line, "/apex/com.android.runtime/bin/linker64")) {
unsigned long start, end, offset;
char perm[8];
sscanf(line, "%lx-%lx %4s %lx", &start, &end, perm, &offset);
if (strstr(perm, "r-xp")) {
fclose(f);
fprintf(stderr, "linker64 r-xp: 0x%lx (file offset 0x%lx)\n", start, offset);
if (file_offset_out) *file_offset_out = offset;
return start;
}
}
}
fclose(f);
fprintf(stderr, "linker64 r-xp not found\n");
return 0;
}
int main(int argc, char *argv[]) {
if (argc < 2) {
fprintf(stderr, "Usage: %s <pid>\n", argv[0]);
return 1;
}
long pid = atol(argv[1]);
unsigned long rx_offset = 0;
unsigned long rx_base = find_linker_rx_base(pid, &rx_offset);
if (!rx_base) {
fprintf(stderr, "Failed to find linker64 r-xp\n");
return 1;
}
unsigned long dlopen_offset = 0x86fa0;
unsigned long dlopen_addr = rx_base + (dlopen_offset - rx_offset);
fprintf(stderr, "dlopen address: 0x%lx (rx_base=0x%lx, sym_offset=0x%lx, rx_offset=0x%lx)\n",
dlopen_addr, rx_base, dlopen_offset, rx_offset);
fprintf(stderr, "Attaching to PID %ld...\n", pid);
if (ptrace(PTRACE_ATTACH, pid, 0, 0) < 0) {
perror("PTRACE_ATTACH");
return 1;
}
int status;
waitpid(pid, &status, WUNTRACED);
if (!WIFSTOPPED(status)) {
fprintf(stderr, "Target did not stop after attach\n");
ptrace(PTRACE_DETACH, pid, 0, 0);
return 1;
}
fprintf(stderr, "Attached, target stopped (signal=%d)\n", WSTOPSIG(status));
struct user_pt_regs saved_regs;
struct iovec iov_save = { &saved_regs, sizeof(saved_regs) };
if (ptrace(PTRACE_GETREGSET, pid, NT_PRSTATUS, &iov_save) < 0) {
perror("PTRACE_GETREGSET (save)");
ptrace(PTRACE_DETACH, pid, 0, 0);
return 1;
}
fprintf(stderr, "Saved: PC=0x%lx SP=0x%lx\n", saved_regs.pc, saved_regs.sp);
unsigned long data_addr = saved_regs.sp - 0x8000;
fprintf(stderr, "Data area at 0x%lx\n", data_addr);
size_t path_len = strlen(LIB_PATH) + 1;
if (ptrace_write_data(pid, data_addr, LIB_PATH, path_len) < 0) {
fprintf(stderr, "Failed to write lib path\n");
ptrace(PTRACE_SETREGSET, pid, NT_PRSTATUS, &iov_save);
ptrace(PTRACE_DETACH, pid, 0, 0);
return 1;
}
fprintf(stderr, "Wrote '%s' to 0x%lx\n", LIB_PATH, data_addr);
unsigned long ret_addr = data_addr + 0x200;
errno = 0;
long orig_at_ret = ptrace(PTRACE_PEEKTEXT, pid, ret_addr, 0);
if (errno) {
perror("PEEKTEXT at return address");
ptrace(PTRACE_SETREGSET, pid, NT_PRSTATUS, &iov_save);
ptrace(PTRACE_DETACH, pid, 0, 0);
return 1;
}
if (ptrace(PTRACE_POKETEXT, pid, ret_addr, (void*)(long)ARM64_BRK) < 0) {
perror("POKETEXT breakpoint");
ptrace(PTRACE_SETREGSET, pid, NT_PRSTATUS, &iov_save);
ptrace(PTRACE_DETACH, pid, 0, 0);
return 1;
}
fprintf(stderr, "Breakpoint at 0x%lx (orig=0x%lx)\n", ret_addr, orig_at_ret);
struct user_pt_regs call_regs = saved_regs;
call_regs.regs[0] = data_addr;
call_regs.regs[1] = 2;
call_regs.regs[2] = saved_regs.regs[30];
call_regs.regs[3] = 0;
call_regs.regs[30] = ret_addr;
call_regs.pc = dlopen_addr;
struct iovec iov_call = { &call_regs, sizeof(call_regs) };
if (ptrace(PTRACE_SETREGSET, pid, NT_PRSTATUS, &iov_call) < 0) {
perror("PTRACE_SETREGSET (call)");
ptrace(PTRACE_POKETEXT, pid, ret_addr, (void*)(long)orig_at_ret);
ptrace(PTRACE_SETREGSET, pid, NT_PRSTATUS, &iov_save);
ptrace(PTRACE_DETACH, pid, 0, 0);
return 1;
}
fprintf(stderr, "Calling dlopen(\"%s\", 2) at 0x%lx...\n", LIB_PATH, dlopen_addr);
ptrace(PTRACE_CONT, pid, 0, 0);
waitpid(pid, &status, WUNTRACED);
if (WIFSTOPPED(status)) {
fprintf(stderr, "Target stopped (signal=%d)\n", WSTOPSIG(status));
} else if (WIFEXITED(status)) {
fprintf(stderr, "Target exited! (code=%d)\n", WEXITSTATUS(status));
} else if (WIFSIGNALED(status)) {
fprintf(stderr, "Target killed! (signal=%d)\n", WTERMSIG(status));
}
ptrace(PTRACE_POKETEXT, pid, ret_addr, (void*)(long)orig_at_ret);
struct user_pt_regs result_regs;
struct iovec iov_result = { &result_regs, sizeof(result_regs) };
if (ptrace(PTRACE_GETREGSET, pid, NT_PRSTATUS, &iov_result) < 0) {
perror("PTRACE_GETREGSET (result)");
} else {
fprintf(stderr, "dlopen returned: 0x%llx\n", (unsigned long long)result_regs.regs[0]);
if (result_regs.regs[0] != 0) {
fprintf(stderr, "SUCCESS! Library loaded.\n");
} else {
fprintf(stderr, "FAILED: dlopen returned NULL\n");
}
}
ptrace(PTRACE_SETREGSET, pid, NT_PRSTATUS, &iov_save);
ptrace(PTRACE_DETACH, pid, 0, 0);
fprintf(stderr, "Detached from PID %ld\n", pid);
return 0;
}

Some files were not shown because too many files have changed in this diff Show More