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