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
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)
}
}