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