commit e5471b5fa087b88425b014ce008a6bce745f658e Author: Jory Severijnse Date: Fri May 8 11:45:15 2026 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..55ecf7b --- /dev/null +++ b/.gitignore @@ -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/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..1cb1fd9 --- /dev/null +++ b/README.md @@ -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/) diff --git a/app/.gitignore b/app/.gitignore new file mode 100755 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle new file mode 100755 index 0000000..baf1163 --- /dev/null +++ b/app/build.gradle @@ -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 +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100755 index 0000000..047e4a0 --- /dev/null +++ b/app/proguard-rules.pro @@ -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 ; +} + +# 假设你的包名是 com.nothing.camera2magic +-keep class com.nothing.camera2magic.** { + *; +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/nothing/camera2magic/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/nothing/camera2magic/ExampleInstrumentedTest.kt new file mode 100755 index 0000000..ec5b8b2 --- /dev/null +++ b/app/src/androidTest/java/com/nothing/camera2magic/ExampleInstrumentedTest.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/legacy/AndroidManifest.xml b/app/src/legacy/AndroidManifest.xml new file mode 100644 index 0000000..d1e0002 --- /dev/null +++ b/app/src/legacy/AndroidManifest.xml @@ -0,0 +1,15 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/legacy/assets/native_init b/app/src/legacy/assets/native_init new file mode 100644 index 0000000..c137fa2 --- /dev/null +++ b/app/src/legacy/assets/native_init @@ -0,0 +1 @@ +camera3 \ No newline at end of file diff --git a/app/src/legacy/assets/xposed_init b/app/src/legacy/assets/xposed_init new file mode 100644 index 0000000..69320aa --- /dev/null +++ b/app/src/legacy/assets/xposed_init @@ -0,0 +1 @@ +com.nothing.camera2magic.MagicHook \ No newline at end of file diff --git a/app/src/legacy/java/com/nothing/camera2magic/MagicHook.kt b/app/src/legacy/java/com/nothing/camera2magic/MagicHook.kt new file mode 100644 index 0000000..2da7a26 --- /dev/null +++ b/app/src/legacy/java/com/nothing/camera2magic/MagicHook.kt @@ -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-- + } + }) + } + +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100755 index 0000000..5a89d74 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png new file mode 100755 index 0000000..8d0fa79 Binary files /dev/null and b/app/src/main/ic_launcher-playstore.png differ diff --git a/app/src/main/java/com/nothing/camera2magic/GlobalState.kt b/app/src/main/java/com/nothing/camera2magic/GlobalState.kt new file mode 100644 index 0000000..5ea946e --- /dev/null +++ b/app/src/main/java/com/nothing/camera2magic/GlobalState.kt @@ -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 +} \ No newline at end of file diff --git a/app/src/main/java/com/nothing/camera2magic/MainActivity.kt b/app/src/main/java/com/nothing/camera2magic/MainActivity.kt new file mode 100755 index 0000000..ee303b5 --- /dev/null +++ b/app/src/main/java/com/nothing/camera2magic/MainActivity.kt @@ -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 + } + } +} diff --git a/app/src/main/java/com/nothing/camera2magic/hook/BlackHoleMapper.kt b/app/src/main/java/com/nothing/camera2magic/hook/BlackHoleMapper.kt new file mode 100644 index 0000000..fa08ad7 --- /dev/null +++ b/app/src/main/java/com/nothing/camera2magic/hook/BlackHoleMapper.kt @@ -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() + 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() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/nothing/camera2magic/hook/CameraState.kt b/app/src/main/java/com/nothing/camera2magic/hook/CameraState.kt new file mode 100644 index 0000000..46c02f6 --- /dev/null +++ b/app/src/main/java/com/nothing/camera2magic/hook/CameraState.kt @@ -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 = mutableSetOf() +) \ No newline at end of file diff --git a/app/src/main/java/com/nothing/camera2magic/hook/HalConfigManager.kt b/app/src/main/java/com/nothing/camera2magic/hook/HalConfigManager.kt new file mode 100644 index 0000000..1fd25a5 --- /dev/null +++ b/app/src/main/java/com/nothing/camera2magic/hook/HalConfigManager.kt @@ -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 = "" + ) +} diff --git a/app/src/main/java/com/nothing/camera2magic/hook/NativeBridge.kt b/app/src/main/java/com/nothing/camera2magic/hook/NativeBridge.kt new file mode 100644 index 0000000..fe15e20 --- /dev/null +++ b/app/src/main/java/com/nothing/camera2magic/hook/NativeBridge.kt @@ -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? = null + + private val surfaceLock = Any() + @Volatile + private var cachedBuffer: ByteArray? = null + + @Volatile + var currentCamera: WeakReference? = null + @Volatile + var previewCallback: WeakReference? = 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 + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/nothing/camera2magic/hook/SourceManager.kt b/app/src/main/java/com/nothing/camera2magic/hook/SourceManager.kt new file mode 100644 index 0000000..1cdccf9 --- /dev/null +++ b/app/src/main/java/com/nothing/camera2magic/hook/SourceManager.kt @@ -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 + } +} \ No newline at end of file diff --git a/app/src/main/java/com/nothing/camera2magic/hook/VideoPusher.kt b/app/src/main/java/com/nothing/camera2magic/hook/VideoPusher.kt new file mode 100644 index 0000000..5b08cb8 --- /dev/null +++ b/app/src/main/java/com/nothing/camera2magic/hook/VideoPusher.kt @@ -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, 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 + } +} diff --git a/app/src/main/java/com/nothing/camera2magic/hook/VirtualCameraRenderer.kt b/app/src/main/java/com/nothing/camera2magic/hook/VirtualCameraRenderer.kt new file mode 100644 index 0000000..eba4cf3 --- /dev/null +++ b/app/src/main/java/com/nothing/camera2magic/hook/VirtualCameraRenderer.kt @@ -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() + private val surfaceSizes = mutableMapOf>() + private val surfaceFormats = mutableMapOf() + private val imageWriters = mutableMapOf() + 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, 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(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() + } + } +} diff --git a/app/src/main/java/com/nothing/camera2magic/ui/theme/Color.kt b/app/src/main/java/com/nothing/camera2magic/ui/theme/Color.kt new file mode 100755 index 0000000..d09c53e --- /dev/null +++ b/app/src/main/java/com/nothing/camera2magic/ui/theme/Color.kt @@ -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) + + + + + + + diff --git a/app/src/main/java/com/nothing/camera2magic/ui/theme/Theme.kt b/app/src/main/java/com/nothing/camera2magic/ui/theme/Theme.kt new file mode 100755 index 0000000..a5980ef --- /dev/null +++ b/app/src/main/java/com/nothing/camera2magic/ui/theme/Theme.kt @@ -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 + ) +} + diff --git a/app/src/main/java/com/nothing/camera2magic/ui/theme/Type.kt b/app/src/main/java/com/nothing/camera2magic/ui/theme/Type.kt new file mode 100755 index 0000000..0cec634 --- /dev/null +++ b/app/src/main/java/com/nothing/camera2magic/ui/theme/Type.kt @@ -0,0 +1,5 @@ +package com.nothing.camera2magic.ui.theme + +import androidx.compose.material3.Typography + +val AppTypography = Typography() diff --git a/app/src/main/java/com/nothing/camera2magic/utils/Dog.kt b/app/src/main/java/com/nothing/camera2magic/utils/Dog.kt new file mode 100644 index 0000000..70e7d31 --- /dev/null +++ b/app/src/main/java/com/nothing/camera2magic/utils/Dog.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/nothing/camera2magic/view/SettingsView.kt b/app/src/main/java/com/nothing/camera2magic/view/SettingsView.kt new file mode 100755 index 0000000..66992af --- /dev/null +++ b/app/src/main/java/com/nothing/camera2magic/view/SettingsView.kt @@ -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 + ) + } + } +} diff --git a/app/src/main/java/com/nothing/camera2magic/view/SpotlightView.kt b/app/src/main/java/com/nothing/camera2magic/view/SpotlightView.kt new file mode 100755 index 0000000..e78123a --- /dev/null +++ b/app/src/main/java/com/nothing/camera2magic/view/SpotlightView.kt @@ -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(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, + labels: Array, + 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, + thumbnails: Map, + 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) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/nothing/camera2magic/viewmodel/CompositionLocals.kt b/app/src/main/java/com/nothing/camera2magic/viewmodel/CompositionLocals.kt new file mode 100755 index 0000000..8813d68 --- /dev/null +++ b/app/src/main/java/com/nothing/camera2magic/viewmodel/CompositionLocals.kt @@ -0,0 +1,10 @@ +package com.nothing.camera2magic.viewmodel + +import androidx.compose.runtime.staticCompositionLocalOf + +val LocalConfigRepository = staticCompositionLocalOf { + error("No ConfigRepository provided") +} +val LocalViewModelFactory = staticCompositionLocalOf { + error("No ViewModelFactory provided") +} \ No newline at end of file diff --git a/app/src/main/java/com/nothing/camera2magic/viewmodel/SettingsViewModel.kt b/app/src/main/java/com/nothing/camera2magic/viewmodel/SettingsViewModel.kt new file mode 100755 index 0000000..4dc96be --- /dev/null +++ b/app/src/main/java/com/nothing/camera2magic/viewmodel/SettingsViewModel.kt @@ -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 +) diff --git a/app/src/main/java/com/nothing/camera2magic/viewmodel/SpotlightViewModel.kt b/app/src/main/java/com/nothing/camera2magic/viewmodel/SpotlightViewModel.kt new file mode 100755 index 0000000..2ca6d78 --- /dev/null +++ b/app/src/main/java/com/nothing/camera2magic/viewmodel/SpotlightViewModel.kt @@ -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>(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) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/nothing/camera2magic/viewmodel/ViewModelFactory.kt b/app/src/main/java/com/nothing/camera2magic/viewmodel/ViewModelFactory.kt new file mode 100755 index 0000000..25c43e5 --- /dev/null +++ b/app/src/main/java/com/nothing/camera2magic/viewmodel/ViewModelFactory.kt @@ -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 create(modelClass: Class): 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}") + } + + } +} diff --git a/app/src/main/jniLibs/README.md b/app/src/main/jniLibs/README.md new file mode 100644 index 0000000..faf8ad6 --- /dev/null +++ b/app/src/main/jniLibs/README.md @@ -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. \ No newline at end of file diff --git a/app/src/main/jniLibs/arm64-v8a/libcamera3.so b/app/src/main/jniLibs/arm64-v8a/libcamera3.so new file mode 100644 index 0000000..2193d78 Binary files /dev/null and b/app/src/main/jniLibs/arm64-v8a/libcamera3.so differ diff --git a/app/src/main/jniLibs/arm64-v8a/libcamera_hook.so b/app/src/main/jniLibs/arm64-v8a/libcamera_hook.so new file mode 100755 index 0000000..ddcf80c Binary files /dev/null and b/app/src/main/jniLibs/arm64-v8a/libcamera_hook.so differ diff --git a/app/src/main/res/drawable/breaking_news_24px.xml b/app/src/main/res/drawable/breaking_news_24px.xml new file mode 100644 index 0000000..a72d73c --- /dev/null +++ b/app/src/main/res/drawable/breaking_news_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/circle_background.xml b/app/src/main/res/drawable/circle_background.xml new file mode 100644 index 0000000..e319f5f --- /dev/null +++ b/app/src/main/res/drawable/circle_background.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/app/src/main/res/drawable/close_24px.xml b/app/src/main/res/drawable/close_24px.xml new file mode 100644 index 0000000..118396c --- /dev/null +++ b/app/src/main/res/drawable/close_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/control_camera_24px.xml b/app/src/main/res/drawable/control_camera_24px.xml new file mode 100644 index 0000000..0db1e11 --- /dev/null +++ b/app/src/main/res/drawable/control_camera_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/developer_board_24px.xml b/app/src/main/res/drawable/developer_board_24px.xml new file mode 100644 index 0000000..4bdb607 --- /dev/null +++ b/app/src/main/res/drawable/developer_board_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100755 index 0000000..07d5da9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100755 index 0000000..1df81c8 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/icon.xml b/app/src/main/res/drawable/icon.xml new file mode 100755 index 0000000..37009a7 --- /dev/null +++ b/app/src/main/res/drawable/icon.xml @@ -0,0 +1,30 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/image_24px.xml b/app/src/main/res/drawable/image_24px.xml new file mode 100644 index 0000000..10ee98d --- /dev/null +++ b/app/src/main/res/drawable/image_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/rotate_90_degrees_cw_24px.xml b/app/src/main/res/drawable/rotate_90_degrees_cw_24px.xml new file mode 100644 index 0000000..648d82b --- /dev/null +++ b/app/src/main/res/drawable/rotate_90_degrees_cw_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/video_file_24px.xml b/app/src/main/res/drawable/video_file_24px.xml new file mode 100644 index 0000000..495865e --- /dev/null +++ b/app/src/main/res/drawable/video_file_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/volume_up_24px.xml b/app/src/main/res/drawable/volume_up_24px.xml new file mode 100644 index 0000000..9282b13 --- /dev/null +++ b/app/src/main/res/drawable/volume_up_24px.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100755 index 0000000..7353dbd --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100755 index 0000000..7353dbd --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100755 index 0000000..f85ae2e Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100755 index 0000000..e95348d Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100755 index 0000000..c4cffe4 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100755 index 0000000..adb1b6c Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100755 index 0000000..10e3e82 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100755 index 0000000..d2df0ed Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100755 index 0000000..2d98ab4 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100755 index 0000000..4019bc8 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100755 index 0000000..50392af Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100755 index 0000000..95fedaa Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml new file mode 100644 index 0000000..732a0fa --- /dev/null +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -0,0 +1,21 @@ + + + CamSwapper + CamSwapper 虚拟摄像头模块,支持 Android 10+ + 需要媒体权限 + 为了加载您的视频和图片作为虚拟摄像头内容,应用需要访问您设备上的媒体文件。 + 授予权限 + + + 本地媒体 + 网络视频 + + 开启模块 + 播放声音 + 打印日志 + 浮动控制台 + 旋转画面 + 拍照 + 扫描二维码 + 人脸识别 + \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100755 index 0000000..f8c6127 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml new file mode 100755 index 0000000..13e32b7 --- /dev/null +++ b/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #F3F0F5 + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100755 index 0000000..c572561 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,35 @@ + + CamSwapper + CamSwapper Virtual Camera Module, supports Android 10+ + + Media Access Required + To load your selected videos and images for the virtual camera, the app needs permission to access media files on your device. + Grant Permission + + + Local + Network + + + Video + Image + + Enable Module + + Play Sound + Enable Log + Inject Menu + Manually Rotate + + Normal + Scan QR Code + Face Recognition + + + HAL Mode + HAL Mode: ON (system-wide) + HAL Mode: OFF (app-level) + HAL config written successfully + Failed to write HAL config (root required) + Root access required for HAL mode + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100755 index 0000000..81c8254 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,4 @@ + + +