Joburgess 1 săptămână în urmă
comite
bff8d4d818
32 a modificat fișierele cu 1368 adăugiri și 0 ștergeri
  1. 9 0
      .gitignore
  2. 81 0
      app/build.gradle.kts
  3. 5 0
      app/proguard-rules.pro
  4. 26 0
      app/src/androidTest/java/com/joe/camera/webcam/ui/HomeScreenTest.kt
  5. 44 0
      app/src/main/AndroidManifest.xml
  6. 6 0
      app/src/main/aidl/com/joe/camera/webcam/IUserService.aidl
  7. 50 0
      app/src/main/java/com/joe/camera/webcam/MainActivity.kt
  8. 88 0
      app/src/main/java/com/joe/camera/webcam/MainViewModel.kt
  9. 9 0
      app/src/main/java/com/joe/camera/webcam/UserService.kt
  10. 5 0
      app/src/main/java/com/joe/camera/webcam/WebCamApplication.kt
  11. 135 0
      app/src/main/java/com/joe/camera/webcam/ble/BleController.kt
  12. 99 0
      app/src/main/java/com/joe/camera/webcam/camera/CameraController.kt
  13. 73 0
      app/src/main/java/com/joe/camera/webcam/data/MediaRepository.kt
  14. 25 0
      app/src/main/java/com/joe/camera/webcam/model/AppModels.kt
  15. 23 0
      app/src/main/java/com/joe/camera/webcam/system/CameraTileService.kt
  16. 46 0
      app/src/main/java/com/joe/camera/webcam/system/ScreenController.kt
  17. 156 0
      app/src/main/java/com/joe/camera/webcam/ui/HomeScreen.kt
  18. 112 0
      app/src/main/java/com/joe/camera/webcam/ui/MediaScreens.kt
  19. 10 0
      app/src/main/java/com/joe/camera/webcam/ui/Theme.kt
  20. 27 0
      app/src/main/java/com/joe/camera/webcam/ui/WebCamApp.kt
  21. 3 0
      app/src/main/res/drawable/ic_tile.xml
  22. 4 0
      app/src/main/res/values/strings.xml
  23. 9 0
      app/src/main/res/values/themes.xml
  24. 19 0
      app/src/test/java/com/joe/camera/webcam/data/MediaRepositoryTest.kt
  25. 20 0
      app/src/test/java/com/joe/camera/webcam/model/AppModelsTest.kt
  26. 5 0
      build.gradle.kts
  27. 4 0
      gradle.properties
  28. BIN
      gradle/wrapper/gradle-wrapper.jar
  29. 7 0
      gradle/wrapper/gradle-wrapper.properties
  30. 160 0
      gradlew
  31. 90 0
      gradlew.bat
  32. 18 0
      settings.gradle.kts

+ 9 - 0
.gitignore

@@ -0,0 +1,9 @@
+*.iml
+.gradle/
+.idea/
+local.properties
+build/
+app/build/
+captures/
+.externalNativeBuild/
+.cxx/

+ 81 - 0
app/build.gradle.kts

@@ -0,0 +1,81 @@
+plugins {
+    id("com.android.application")
+    id("org.jetbrains.kotlin.android")
+    id("org.jetbrains.kotlin.plugin.compose")
+}
+
+android {
+    namespace = "com.joe.camera.webcam"
+    compileSdk = 35
+
+    defaultConfig {
+        applicationId = "com.joe.camera.webcam"
+        minSdk = 24
+        targetSdk = 35
+        versionCode = 1
+        versionName = "1.0.0"
+        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+        vectorDrawables.useSupportLibrary = true
+    }
+
+    buildFeatures {
+        compose = true
+        aidl = true
+        buildConfig = true
+    }
+
+    compileOptions {
+        sourceCompatibility = JavaVersion.VERSION_17
+        targetCompatibility = JavaVersion.VERSION_17
+    }
+    kotlinOptions { jvmTarget = "17" }
+
+    buildTypes {
+        release {
+            isMinifyEnabled = true
+            isShrinkResources = true
+            proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
+        }
+    }
+
+    packaging.resources.excludes += "/META-INF/{AL2.0,LGPL2.1}"
+}
+
+dependencies {
+    val composeBom = platform("androidx.compose:compose-bom:2024.12.01")
+    implementation(composeBom)
+    androidTestImplementation(composeBom)
+
+    implementation("androidx.core:core-ktx:1.15.0")
+    implementation("androidx.activity:activity-compose:1.10.0")
+    implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
+    implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
+    implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
+    implementation("androidx.navigation:navigation-compose:2.8.5")
+    implementation("androidx.compose.ui:ui")
+    implementation("androidx.compose.ui:ui-tooling-preview")
+    implementation("androidx.compose.material3:material3")
+    implementation("androidx.compose.material:material-icons-extended")
+    debugImplementation("androidx.compose.ui:ui-tooling")
+
+    val cameraX = "1.4.1"
+    implementation("androidx.camera:camera-core:$cameraX")
+    implementation("androidx.camera:camera-camera2:$cameraX")
+    implementation("androidx.camera:camera-lifecycle:$cameraX")
+    implementation("androidx.camera:camera-video:$cameraX")
+    implementation("androidx.camera:camera-view:$cameraX")
+
+    implementation("androidx.media3:media3-exoplayer:1.5.1")
+    implementation("androidx.media3:media3-ui:1.5.1")
+    implementation("io.coil-kt:coil-compose:2.7.0")
+    implementation("io.coil-kt:coil-video:2.7.0")
+    implementation("dev.rikka.shizuku:api:13.1.5")
+    implementation("dev.rikka.shizuku:provider:13.1.5")
+
+    testImplementation("junit:junit:4.13.2")
+    testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
+    androidTestImplementation("androidx.test.ext:junit:1.2.1")
+    androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
+    androidTestImplementation("androidx.compose.ui:ui-test-junit4")
+    debugImplementation("androidx.compose.ui:ui-test-manifest")
+}

+ 5 - 0
app/proguard-rules.pro

@@ -0,0 +1,5 @@
+-keep class rikka.shizuku.** { *; }
+-keep class com.joe.camera.webcam.IUserService { *; }
+-keep class com.joe.camera.webcam.IUserService$Stub { *; }
+-keep class com.joe.camera.webcam.UserService { *; }
+-keepattributes *Annotation*

+ 26 - 0
app/src/androidTest/java/com/joe/camera/webcam/ui/HomeScreenTest.kt

@@ -0,0 +1,26 @@
+package com.joe.camera.webcam.ui
+
+import android.app.Application
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithContentDescription
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performTouchInput
+import androidx.compose.ui.test.doubleClick
+import androidx.test.core.app.ApplicationProvider
+import com.joe.camera.webcam.MainUiState
+import com.joe.camera.webcam.MainViewModel
+import org.junit.Rule
+import org.junit.Test
+
+class HomeScreenTest {
+    @get:Rule val compose = createComposeRule()
+
+    @Test fun doubleTapOpensControlMenu() {
+        val viewModel = MainViewModel(ApplicationProvider.getApplicationContext<Application>())
+        compose.setContent { WebCamTheme { HomeScreen(viewModel, MainUiState(remoteMode = true), onMedia = {}) } }
+        compose.onNodeWithContentDescription("控制菜单").performTouchInput { doubleClick() }
+        compose.onNodeWithText("录像").assertIsDisplayed()
+        compose.onNodeWithText("媒体库").assertIsDisplayed()
+    }
+}

+ 44 - 0
app/src/main/AndroidManifest.xml

@@ -0,0 +1,44 @@
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+    <uses-feature android:name="android.hardware.camera.any" android:required="true" />
+    <uses-feature android:name="android.hardware.bluetooth_le" android:required="false" />
+
+    <uses-permission android:name="android.permission.CAMERA" />
+    <uses-permission android:name="android.permission.INTERNET" />
+    <uses-permission android:name="android.permission.WAKE_LOCK" />
+    <uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" />
+    <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
+    <uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
+    <uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
+    <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
+    <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="30" />
+    <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" android:maxSdkVersion="30" />
+    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
+    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
+
+    <application
+        android:name=".WebCamApplication"
+        android:allowBackup="true"
+        android:icon="@drawable/ic_tile"
+        android:label="@string/app_name"
+        android:supportsRtl="true"
+        android:theme="@style/Theme.WebCam"
+        android:usesCleartextTraffic="true">
+        <activity android:name=".MainActivity" android:exported="true" android:launchMode="singleTop">
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN" />
+                <action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES" />
+                <category android:name="android.intent.category.LAUNCHER" />
+            </intent-filter>
+        </activity>
+        <service android:name=".system.CameraTileService" android:exported="true" android:icon="@drawable/ic_tile" android:label="@string/tile_name" android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
+            <intent-filter><action android:name="android.service.quicksettings.action.QS_TILE" /></intent-filter>
+        </service>
+        <provider
+            android:name="rikka.shizuku.ShizukuProvider"
+            android:authorities="${applicationId}.shizuku"
+            android:enabled="true"
+            android:exported="true"
+            android:multiprocess="false"
+            android:permission="android.permission.INTERACT_ACROSS_USERS_FULL" />
+    </application>
+</manifest>

+ 6 - 0
app/src/main/aidl/com/joe/camera/webcam/IUserService.aidl

@@ -0,0 +1,6 @@
+package com.joe.camera.webcam;
+
+interface IUserService {
+    void destroy() = 16777114;
+    String exec(String command) = 1;
+}

+ 50 - 0
app/src/main/java/com/joe/camera/webcam/MainActivity.kt

@@ -0,0 +1,50 @@
+package com.joe.camera.webcam
+
+import android.Manifest
+import android.os.Build
+import android.os.Bundle
+import android.view.KeyEvent
+import android.view.WindowManager
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.activity.viewModels
+import com.joe.camera.webcam.ui.WebCamApp
+
+class MainActivity : ComponentActivity() {
+    private val viewModel: MainViewModel by viewModels()
+    private val permissionLauncher = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions ->
+        if (permissions[Manifest.permission.CAMERA] == true) viewModel.startBle()
+    }
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        enableEdgeToEdge()
+        window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
+        requestPermissions()
+        setContent { WebCamApp(viewModel) }
+    }
+
+    private fun requestPermissions() {
+        val permissions = buildList {
+            add(Manifest.permission.CAMERA)
+            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+                add(Manifest.permission.BLUETOOTH_SCAN); add(Manifest.permission.BLUETOOTH_CONNECT); add(Manifest.permission.BLUETOOTH_ADVERTISE)
+            } else { add(Manifest.permission.ACCESS_FINE_LOCATION) }
+            if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) add(Manifest.permission.WRITE_EXTERNAL_STORAGE)
+        }
+        permissionLauncher.launch(permissions.toTypedArray())
+    }
+
+    override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean = when (keyCode) {
+        KeyEvent.KEYCODE_VOLUME_UP, KeyEvent.KEYCODE_VOLUME_DOWN -> true
+        else -> super.onKeyDown(keyCode, event)
+    }
+    override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean = when (keyCode) {
+        KeyEvent.KEYCODE_VOLUME_UP -> { viewModel.toggleRecording(); true }
+        KeyEvent.KEYCODE_VOLUME_DOWN -> { viewModel.toggleBlackScreen(); true }
+        else -> super.onKeyUp(keyCode, event)
+    }
+    override fun onPause() { viewModel.stopRecording(); super.onPause() }
+}

+ 88 - 0
app/src/main/java/com/joe/camera/webcam/MainViewModel.kt

@@ -0,0 +1,88 @@
+package com.joe.camera.webcam
+
+import android.app.Application
+import android.content.pm.PackageManager
+import androidx.camera.core.CameraSelector
+import androidx.camera.view.PreviewView
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.viewModelScope
+import com.joe.camera.webcam.ble.BleController
+import com.joe.camera.webcam.camera.CameraController
+import com.joe.camera.webcam.data.MediaRepository
+import com.joe.camera.webcam.model.*
+import com.joe.camera.webcam.system.ScreenController
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+
+data class MainUiState(
+    val cameraState: CameraState = CameraState.FREE,
+    val remoteMode: Boolean = false,
+    val previewPaused: Boolean = false,
+    val blackScreen: Boolean = false,
+    val message: String? = null,
+    val media: List<MediaItem> = emptyList(),
+)
+
+class MainViewModel(application: Application) : AndroidViewModel(application) {
+    private val preferences = application.getSharedPreferences("webcam", 0)
+    private val repository = MediaRepository(application)
+    private val screenController = ScreenController(application)
+    private val _uiState = MutableStateFlow(MainUiState(remoteMode = preferences.getBoolean("remote_mode", false)))
+    val uiState = _uiState.asStateFlow()
+    var selectedMedia: MediaItem? = null
+        private set
+
+    private val cameraController = CameraController(application, ::updateCameraState, ::message)
+    private val bleController = BleController(application, ::handleRemoteCommand, ::updateCameraState, ::message)
+
+    init { screenController.prepare() }
+
+    fun startBle() { if (_uiState.value.remoteMode) bleController.startCentral() else bleController.startPeripheral() }
+    fun bindCamera(owner: LifecycleOwner, view: PreviewView) { if (!_uiState.value.remoteMode) cameraController.bind(owner, view) }
+    fun switchLens(front: Boolean) = cameraController.switchLens(if (front) CameraSelector.LENS_FACING_FRONT else CameraSelector.LENS_FACING_BACK)
+    fun takePicture() { if (!_uiState.value.remoteMode && _uiState.value.cameraState == CameraState.FREE) cameraController.takePicture() }
+    fun toggleRecording() {
+        if (_uiState.value.remoteMode) bleController.sendCommand(RemoteCommand.VOLUME_UP)
+        else if (_uiState.value.cameraState != CameraState.WORKING) cameraController.toggleRecording()
+    }
+    fun stopRecording() { cameraController.stopRecording() }
+    fun togglePreview() { cameraController.togglePreview(); _uiState.value = _uiState.value.copy(previewPaused = cameraController.previewPaused) }
+    fun toggleRemoteMode() {
+        val remote = !_uiState.value.remoteMode
+        preferences.edit().putBoolean("remote_mode", remote).apply()
+        _uiState.value = _uiState.value.copy(remoteMode = remote, cameraState = CameraState.FREE)
+        cameraController.release()
+        if (remote) bleController.startCentral() else bleController.startPeripheral()
+    }
+    fun toggleBlackScreen() {
+        val off = !_uiState.value.blackScreen
+        _uiState.value = _uiState.value.copy(blackScreen = off)
+        if (off) viewModelScope.launch(Dispatchers.IO) {
+            val mode = screenController.togglePhysicalScreen(true)
+            message(when (mode) { ScreenControlMode.SHIZUKU -> "已通过 Shizuku 关屏"; ScreenControlMode.ROOT -> "已通过 root 关屏"; ScreenControlMode.OVERLAY -> "已使用应用内黑屏" })
+        }
+    }
+    fun loadMedia() = viewModelScope.launch(Dispatchers.IO) { _uiState.value = _uiState.value.copy(media = repository.loadMedia()) }
+    fun selectMedia(item: MediaItem) { selectedMedia = item }
+    fun deleteSelected(): Boolean {
+        val item = selectedMedia ?: return false
+        val deleted = repository.delete(item)
+        if (deleted) { selectedMedia = null; loadMedia() }
+        return deleted
+    }
+    fun clearMessage() { _uiState.value = _uiState.value.copy(message = null) }
+    fun hasFrontCamera(): Boolean = getApplication<Application>().packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_FRONT)
+
+    private fun handleRemoteCommand(command: RemoteCommand) = when (command) {
+        RemoteCommand.VOLUME_UP, RemoteCommand.RECORD_VIDEO -> toggleRecording()
+        RemoteCommand.VOLUME_DOWN -> toggleBlackScreen()
+        RemoteCommand.OPEN_SETTING -> message("请在设备上打开相机设置")
+    }
+    private fun updateCameraState(state: CameraState) { _uiState.value = _uiState.value.copy(cameraState = state); bleController.publishState(state) }
+    private fun message(text: String) { _uiState.value = _uiState.value.copy(message = text) }
+
+    override fun onCleared() { cameraController.release(); bleController.release(); screenController.release(); super.onCleared() }
+}

+ 9 - 0
app/src/main/java/com/joe/camera/webcam/UserService.kt

@@ -0,0 +1,9 @@
+package com.joe.camera.webcam
+
+class UserService : IUserService.Stub() {
+    override fun destroy() = Unit
+    override fun exec(command: String?): String = runCatching {
+        val process = Runtime.getRuntime().exec(arrayOf("sh", "-c", command.orEmpty()))
+        process.inputStream.bufferedReader().readText()
+    }.getOrElse { it.message.orEmpty() }
+}

+ 5 - 0
app/src/main/java/com/joe/camera/webcam/WebCamApplication.kt

@@ -0,0 +1,5 @@
+package com.joe.camera.webcam
+
+import android.app.Application
+
+class WebCamApplication : Application()

+ 135 - 0
app/src/main/java/com/joe/camera/webcam/ble/BleController.kt

@@ -0,0 +1,135 @@
+package com.joe.camera.webcam.ble
+
+import android.annotation.SuppressLint
+import android.bluetooth.*
+import android.bluetooth.le.*
+import android.content.Context
+import android.os.ParcelUuid
+import com.joe.camera.webcam.model.CameraState
+import com.joe.camera.webcam.model.RemoteCommand
+import java.util.UUID
+
+@SuppressLint("MissingPermission")
+class BleController(
+    context: Context,
+    private val onCommand: (RemoteCommand) -> Unit,
+    private val onRemoteState: (CameraState) -> Unit,
+    private val onMessage: (String) -> Unit,
+) {
+    companion object {
+        val SERVICE_UUID: UUID = UUID.fromString("0000fff0-0000-1000-8000-00805F9B34FB")
+        val CHARACTERISTIC_UUID: UUID = UUID.fromString("0000fff2-0000-1000-8000-00805F9B34FB")
+        val CLIENT_CONFIG_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
+    }
+    private val manager = context.getSystemService(BluetoothManager::class.java)
+    private val adapter get() = manager?.adapter
+    private val appContext = context.applicationContext
+    private var gattServer: BluetoothGattServer? = null
+    private var serverCharacteristic: BluetoothGattCharacteristic? = null
+    private val serverDevices = linkedSetOf<BluetoothDevice>()
+    private var advertiser: BluetoothLeAdvertiser? = null
+    private var scanner: BluetoothLeScanner? = null
+    private var clientGatt: BluetoothGatt? = null
+    private var clientCharacteristic: BluetoothGattCharacteristic? = null
+    private var currentState = CameraState.FREE
+
+    fun startPeripheral() {
+        stopCentral()
+        if (adapter?.isEnabled != true) return onMessage("蓝牙未开启")
+        advertiser = adapter?.bluetoothLeAdvertiser ?: return onMessage("此设备不支持 BLE 外围广播")
+        gattServer?.close()
+        gattServer = manager.openGattServer(appContext, serverCallback)
+        serverCharacteristic = BluetoothGattCharacteristic(
+            CHARACTERISTIC_UUID,
+            BluetoothGattCharacteristic.PROPERTY_READ or BluetoothGattCharacteristic.PROPERTY_WRITE or BluetoothGattCharacteristic.PROPERTY_NOTIFY,
+            BluetoothGattCharacteristic.PERMISSION_READ or BluetoothGattCharacteristic.PERMISSION_WRITE,
+        ).also { characteristic ->
+            characteristic.addDescriptor(BluetoothGattDescriptor(CLIENT_CONFIG_UUID, BluetoothGattDescriptor.PERMISSION_READ or BluetoothGattDescriptor.PERMISSION_WRITE))
+            val service = BluetoothGattService(SERVICE_UUID, BluetoothGattService.SERVICE_TYPE_PRIMARY)
+            service.addCharacteristic(characteristic)
+            gattServer?.addService(service)
+        }
+        val settings = AdvertiseSettings.Builder().setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY).setConnectable(true).build()
+        val data = AdvertiseData.Builder().setIncludeDeviceName(false).addServiceUuid(ParcelUuid(SERVICE_UUID)).build()
+        advertiser?.startAdvertising(settings, data, advertiseCallback)
+    }
+
+    fun startCentral() {
+        stopPeripheral()
+        if (adapter?.isEnabled != true) return onMessage("蓝牙未开启")
+        scanner = adapter?.bluetoothLeScanner ?: return onMessage("BLE 扫描不可用")
+        val filter = ScanFilter.Builder().setServiceUuid(ParcelUuid(SERVICE_UUID)).build()
+        scanner?.startScan(listOf(filter), ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build(), scanCallback)
+        onMessage("设备扫描中…")
+    }
+
+    fun sendCommand(command: RemoteCommand) {
+        val gatt = clientGatt ?: return startCentral()
+        val characteristic = clientCharacteristic ?: return onMessage("遥控设备尚未就绪")
+        characteristic.value = byteArrayOf(command.code)
+        gatt.writeCharacteristic(characteristic)
+    }
+
+    fun publishState(state: CameraState) {
+        currentState = state
+        val characteristic = serverCharacteristic ?: return
+        characteristic.value = byteArrayOf(state.wireCode)
+        serverDevices.forEach { gattServer?.notifyCharacteristicChanged(it, characteristic, false) }
+    }
+
+    private val advertiseCallback = object : AdvertiseCallback() {
+        override fun onStartSuccess(settingsInEffect: AdvertiseSettings?) = onMessage("BLE 相机服务已启动")
+        override fun onStartFailure(errorCode: Int) = onMessage("BLE 广播失败:$errorCode")
+    }
+    private val serverCallback = object : BluetoothGattServerCallback() {
+        override fun onConnectionStateChange(device: BluetoothDevice, status: Int, newState: Int) {
+            if (newState == BluetoothProfile.STATE_CONNECTED) serverDevices += device else serverDevices -= device
+        }
+        override fun onCharacteristicReadRequest(device: BluetoothDevice, requestId: Int, offset: Int, characteristic: BluetoothGattCharacteristic) {
+            gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, 0, byteArrayOf(currentState.wireCode))
+        }
+        override fun onCharacteristicWriteRequest(device: BluetoothDevice, requestId: Int, characteristic: BluetoothGattCharacteristic, preparedWrite: Boolean, responseNeeded: Boolean, offset: Int, value: ByteArray) {
+            value.firstOrNull()?.let { RemoteCommand.fromCode(it)?.let(onCommand) }
+            if (responseNeeded) gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, 0, null)
+        }
+        override fun onDescriptorWriteRequest(device: BluetoothDevice, requestId: Int, descriptor: BluetoothGattDescriptor, preparedWrite: Boolean, responseNeeded: Boolean, offset: Int, value: ByteArray) {
+            if (responseNeeded) gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, 0, null)
+        }
+    }
+    private val scanCallback = object : ScanCallback() {
+        override fun onScanResult(callbackType: Int, result: ScanResult) {
+            scanner?.stopScan(this)
+            clientGatt?.close()
+            clientGatt = result.device.connectGatt(appContext, false, clientCallback, BluetoothDevice.TRANSPORT_LE)
+        }
+        override fun onScanFailed(errorCode: Int) = onMessage("BLE 扫描失败:$errorCode")
+    }
+    private val clientCallback = object : BluetoothGattCallback() {
+        override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
+            if (newState == BluetoothProfile.STATE_CONNECTED) { onMessage("已连接:${gatt.device.name ?: gatt.device.address}"); gatt.discoverServices() }
+            else { clientCharacteristic = null; onRemoteState(CameraState.FREE); onMessage("遥控连接已断开") }
+        }
+        override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
+            clientCharacteristic = gatt.getService(SERVICE_UUID)?.getCharacteristic(CHARACTERISTIC_UUID)
+            val characteristic = clientCharacteristic ?: return onMessage("远端服务不兼容")
+            gatt.setCharacteristicNotification(characteristic, true)
+            characteristic.getDescriptor(CLIENT_CONFIG_UUID)?.let { descriptor ->
+                descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
+                gatt.writeDescriptor(descriptor)
+            }
+        }
+        override fun onDescriptorWrite(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) {
+            if (status == BluetoothGatt.GATT_SUCCESS) clientCharacteristic?.let(gatt::readCharacteristic)
+        }
+        @Deprecated("Deprecated in API 33")
+        override fun onCharacteristicRead(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) { characteristic.value?.firstOrNull()?.let { onRemoteState(CameraState.fromWire(it)) } }
+        @Deprecated("Deprecated in API 33")
+        override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) { characteristic.value?.firstOrNull()?.let { onRemoteState(CameraState.fromWire(it)) } }
+        override fun onCharacteristicRead(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray, status: Int) { value.firstOrNull()?.let { onRemoteState(CameraState.fromWire(it)) } }
+        override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray) { value.firstOrNull()?.let { onRemoteState(CameraState.fromWire(it)) } }
+    }
+
+    fun stopPeripheral() { runCatching { advertiser?.stopAdvertising(advertiseCallback) }; advertiser = null; serverDevices.clear(); gattServer?.close(); gattServer = null }
+    fun stopCentral() { runCatching { scanner?.stopScan(scanCallback) }; scanner = null; clientGatt?.disconnect(); clientGatt?.close(); clientGatt = null; clientCharacteristic = null }
+    fun release() { stopCentral(); stopPeripheral() }
+}

+ 99 - 0
app/src/main/java/com/joe/camera/webcam/camera/CameraController.kt

@@ -0,0 +1,99 @@
+package com.joe.camera.webcam.camera
+
+import android.content.Context
+import android.provider.MediaStore
+import androidx.camera.core.Camera
+import androidx.camera.core.CameraSelector
+import androidx.camera.core.ImageCapture
+import androidx.camera.core.ImageCaptureException
+import androidx.camera.core.Preview
+import androidx.camera.lifecycle.ProcessCameraProvider
+import androidx.camera.video.MediaStoreOutputOptions
+import androidx.camera.video.FallbackStrategy
+import androidx.camera.video.PendingRecording
+import androidx.camera.video.Quality
+import androidx.camera.video.QualitySelector
+import androidx.camera.video.Recorder
+import androidx.camera.video.Recording
+import androidx.camera.video.VideoCapture
+import androidx.camera.video.VideoRecordEvent
+import androidx.camera.view.PreviewView
+import androidx.core.content.ContextCompat
+import androidx.lifecycle.LifecycleOwner
+import com.joe.camera.webcam.data.MediaRepository
+import com.joe.camera.webcam.model.CameraState
+import java.util.concurrent.Executor
+
+class CameraController(private val context: Context, private val onState: (CameraState) -> Unit, private val onMessage: (String) -> Unit) {
+    private val executor: Executor = ContextCompat.getMainExecutor(context)
+    private var provider: ProcessCameraProvider? = null
+    private var preview: Preview? = null
+    private var imageCapture: ImageCapture? = null
+    private var videoCapture: VideoCapture<Recorder>? = null
+    private var recording: Recording? = null
+    private var camera: Camera? = null
+    private var lifecycleOwner: LifecycleOwner? = null
+    private var previewView: PreviewView? = null
+    var lensFacing: Int = CameraSelector.LENS_FACING_BACK
+        private set
+    var previewPaused = false
+        private set
+
+    fun bind(owner: LifecycleOwner, view: PreviewView, facing: Int = lensFacing) {
+        lifecycleOwner = owner
+        previewView = view
+        lensFacing = facing
+        val future = ProcessCameraProvider.getInstance(context)
+        future.addListener({
+            runCatching {
+                provider = future.get()
+                provider?.unbindAll()
+                preview = Preview.Builder().build().also { it.surfaceProvider = view.surfaceProvider }
+                imageCapture = ImageCapture.Builder().setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY).build()
+                val recorder = Recorder.Builder().setQualitySelector(QualitySelector.fromOrderedList(listOf(Quality.UHD, Quality.FHD, Quality.HD), FallbackStrategy.lowerQualityOrHigherThan(Quality.HD))).build()
+                videoCapture = VideoCapture.withOutput(recorder)
+                camera = provider?.bindToLifecycle(owner, CameraSelector.Builder().requireLensFacing(facing).build(), preview, imageCapture, videoCapture)
+                camera?.cameraControl?.enableTorch(false)
+                onState(CameraState.FREE)
+            }.onFailure { onState(CameraState.ERROR); onMessage("相机初始化失败:${it.message.orEmpty()}") }
+        }, executor)
+    }
+
+    fun switchLens(facing: Int) { lifecycleOwner?.let { owner -> previewView?.let { bind(owner, it, facing) } } }
+
+    fun togglePreview() {
+        previewPaused = !previewPaused
+        preview?.surfaceProvider = if (previewPaused) null else previewView?.surfaceProvider
+    }
+
+    fun takePicture() {
+        val capture = imageCapture ?: return onMessage("相机尚未就绪")
+        onState(CameraState.WORKING)
+        val options = ImageCapture.OutputFileOptions.Builder(context.contentResolver, MediaStore.Images.Media.EXTERNAL_CONTENT_URI, MediaRepository(context).imageValues()).build()
+        capture.takePicture(options, executor, object : ImageCapture.OnImageSavedCallback {
+            override fun onImageSaved(output: ImageCapture.OutputFileResults) { onState(CameraState.FREE); onMessage("照片已保存到 Download/jf") }
+            override fun onError(exception: ImageCaptureException) { onState(CameraState.ERROR); onMessage("拍照失败:${exception.message}") }
+        })
+    }
+
+    fun toggleRecording() {
+        recording?.let { it.stop(); return }
+        val capture = videoCapture ?: return onMessage("相机尚未就绪")
+        val options = MediaStoreOutputOptions.Builder(context.contentResolver, MediaStore.Video.Media.EXTERNAL_CONTENT_URI).setContentValues(MediaRepository(context).videoValues()).build()
+        val pending: PendingRecording = capture.output.prepareRecording(context, options)
+        onState(CameraState.WORKING)
+        recording = pending.start(executor) { event ->
+            when (event) {
+                is VideoRecordEvent.Start -> onState(CameraState.RECORDING)
+                is VideoRecordEvent.Finalize -> {
+                    recording = null
+                    if (event.hasError()) { onState(CameraState.ERROR); onMessage("录像失败:${event.error}") }
+                    else { onState(CameraState.FREE); onMessage("视频已保存到 Download/jf") }
+                }
+            }
+        }
+    }
+
+    fun stopRecording() { recording?.stop() }
+    fun release() { recording?.stop(); recording = null; provider?.unbindAll() }
+}

+ 73 - 0
app/src/main/java/com/joe/camera/webcam/data/MediaRepository.kt

@@ -0,0 +1,73 @@
+package com.joe.camera.webcam.data
+
+import android.content.ContentUris
+import android.content.ContentValues
+import android.content.Context
+import android.net.Uri
+import android.os.Build
+import android.os.Environment
+import android.provider.MediaStore
+import com.joe.camera.webcam.model.MediaItem
+import com.joe.camera.webcam.model.MediaKind
+import java.io.File
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+class MediaRepository(private val context: Context) {
+    companion object {
+        val RELATIVE_DIR = "${Environment.DIRECTORY_DOWNLOADS}/jf"
+        const val IMAGE_MIME = "image/jpeg"
+        const val VIDEO_MIME = "video/mp4"
+        fun timestamp(): String = SimpleDateFormat("yyyyMMddHHmmssSSS", Locale.US).format(Date())
+    }
+
+    fun imageValues() = contentValues("${timestamp()}.jp", IMAGE_MIME)
+    fun videoValues() = contentValues("${timestamp()}.jv", VIDEO_MIME)
+
+    private fun contentValues(name: String, mime: String) = ContentValues().apply {
+        put(MediaStore.MediaColumns.DISPLAY_NAME, name)
+        put(MediaStore.MediaColumns.MIME_TYPE, mime)
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+            put(MediaStore.MediaColumns.RELATIVE_PATH, RELATIVE_DIR)
+        } else {
+            @Suppress("DEPRECATION")
+            put(MediaStore.MediaColumns.DATA, File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), "jf/$name").absolutePath)
+        }
+    }
+
+    fun loadMedia(): List<MediaItem> {
+        val result = mutableListOf<MediaItem>()
+        queryCollection(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, MediaKind.IMAGE, result)
+        queryCollection(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, MediaKind.VIDEO, result)
+        return result.sortedByDescending(MediaItem::modifiedSeconds)
+    }
+
+    private fun queryCollection(collection: Uri, kind: MediaKind, output: MutableList<MediaItem>) {
+        val projection = arrayOf(MediaStore.MediaColumns._ID, MediaStore.MediaColumns.DISPLAY_NAME, MediaStore.MediaColumns.DATE_MODIFIED)
+        val selection: String
+        val args: Array<String>
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+            selection = "${MediaStore.MediaColumns.RELATIVE_PATH}=? AND (${MediaStore.MediaColumns.DISPLAY_NAME} LIKE ? OR ${MediaStore.MediaColumns.DISPLAY_NAME} LIKE ?)"
+            args = arrayOf("$RELATIVE_DIR/", "%.jp", "%.jv")
+        } else {
+            @Suppress("DEPRECATION")
+            selection = "${MediaStore.MediaColumns.DATA} LIKE ? AND (${MediaStore.MediaColumns.DISPLAY_NAME} LIKE ? OR ${MediaStore.MediaColumns.DISPLAY_NAME} LIKE ?)"
+            args = arrayOf("%/Download/jf/%", "%.jp", "%.jv")
+        }
+        runCatching {
+            context.contentResolver.query(collection, projection, selection, args, "${MediaStore.MediaColumns.DATE_MODIFIED} DESC")?.use { cursor ->
+                val idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID)
+                val nameColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME)
+                val dateColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED)
+                while (cursor.moveToNext()) {
+                    val name = cursor.getString(nameColumn)
+                    val actualKind = if (name.endsWith(".jp", true)) MediaKind.IMAGE else MediaKind.VIDEO
+                    if (actualKind == kind) output += MediaItem(cursor.getLong(idColumn), ContentUris.withAppendedId(collection, cursor.getLong(idColumn)), name, kind, cursor.getLong(dateColumn))
+                }
+            }
+        }
+    }
+
+    fun delete(item: MediaItem): Boolean = runCatching { context.contentResolver.delete(item.uri, null, null) > 0 }.getOrDefault(false)
+}

+ 25 - 0
app/src/main/java/com/joe/camera/webcam/model/AppModels.kt

@@ -0,0 +1,25 @@
+package com.joe.camera.webcam.model
+
+import android.net.Uri
+
+enum class CameraState(val wireCode: Byte) {
+    FREE(0), WORKING(1), RECORDING(1), ERROR(0);
+    companion object { fun fromWire(value: Byte) = if (value.toInt() == 1) WORKING else FREE }
+}
+
+enum class RemoteCommand(val code: Byte) {
+    VOLUME_UP(0), VOLUME_DOWN(1), RECORD_VIDEO(2), OPEN_SETTING(3);
+    companion object { fun fromCode(code: Byte) = entries.firstOrNull { it.code == code } }
+}
+
+enum class MediaKind { IMAGE, VIDEO }
+
+data class MediaItem(
+    val id: Long,
+    val uri: Uri,
+    val displayName: String,
+    val kind: MediaKind,
+    val modifiedSeconds: Long,
+)
+
+enum class ScreenControlMode { SHIZUKU, ROOT, OVERLAY }

+ 23 - 0
app/src/main/java/com/joe/camera/webcam/system/CameraTileService.kt

@@ -0,0 +1,23 @@
+package com.joe.camera.webcam.system
+
+import android.annotation.SuppressLint
+import android.content.Intent
+import android.app.PendingIntent
+import android.os.Build
+import android.service.quicksettings.TileService
+import com.joe.camera.webcam.MainActivity
+
+class CameraTileService : TileService() {
+    @SuppressLint("StartActivityAndCollapseDeprecated")
+    override fun onClick() {
+        super.onClick()
+        val intent = Intent(this, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+            val pendingIntent = PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
+            startActivityAndCollapse(pendingIntent)
+        } else {
+            @Suppress("DEPRECATION")
+            startActivityAndCollapse(intent)
+        }
+    }
+}

+ 46 - 0
app/src/main/java/com/joe/camera/webcam/system/ScreenController.kt

@@ -0,0 +1,46 @@
+package com.joe.camera.webcam.system
+
+import android.content.ComponentName
+import android.content.Context
+import android.content.ServiceConnection
+import android.content.pm.PackageManager
+import android.os.IBinder
+import com.joe.camera.webcam.BuildConfig
+import com.joe.camera.webcam.IUserService
+import com.joe.camera.webcam.UserService
+import com.joe.camera.webcam.model.ScreenControlMode
+import rikka.shizuku.Shizuku
+
+class ScreenController(private val context: Context) {
+    private var service: IUserService? = null
+    private val args = Shizuku.UserServiceArgs(ComponentName(BuildConfig.APPLICATION_ID, UserService::class.java.name)).daemon(false).processNameSuffix("screen").debuggable(BuildConfig.DEBUG).version(BuildConfig.VERSION_CODE)
+    private val connection = object : ServiceConnection {
+        override fun onServiceConnected(name: ComponentName?, binder: IBinder?) { service = IUserService.Stub.asInterface(binder) }
+        override fun onServiceDisconnected(name: ComponentName?) { service = null }
+    }
+    private val permissionListener = Shizuku.OnRequestPermissionResultListener { requestCode, grantResult ->
+        if (requestCode == 42 && grantResult == PackageManager.PERMISSION_GRANTED) runCatching { Shizuku.bindUserService(args, connection) }
+    }
+    private val binderListener = Shizuku.OnBinderReceivedListener {
+        if (Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED) runCatching { Shizuku.bindUserService(args, connection) }
+        else Shizuku.requestPermission(42)
+    }
+
+    fun prepare() {
+        runCatching {
+            Shizuku.addRequestPermissionResultListener(permissionListener)
+            Shizuku.addBinderReceivedListenerSticky(binderListener)
+            if (Shizuku.pingBinder() && Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED) Shizuku.bindUserService(args, connection)
+            else if (Shizuku.pingBinder()) Shizuku.requestPermission(42)
+        }
+    }
+
+    fun togglePhysicalScreen(off: Boolean): ScreenControlMode {
+        if (off && service != null) { runCatching { service?.exec("input keyevent 26") }; return ScreenControlMode.SHIZUKU }
+        if (off && hasRoot()) { runCatching { Runtime.getRuntime().exec(arrayOf("su", "-c", "input keyevent 26")) }; return ScreenControlMode.ROOT }
+        return ScreenControlMode.OVERLAY
+    }
+
+    private fun hasRoot(): Boolean = runCatching { Runtime.getRuntime().exec(arrayOf("su", "-c", "id")).waitFor() == 0 }.getOrDefault(false)
+    fun release() { runCatching { Shizuku.unbindUserService(args, connection, true) }; runCatching { Shizuku.removeRequestPermissionResultListener(permissionListener) }; runCatching { Shizuku.removeBinderReceivedListener(binderListener) }; service = null }
+}

+ 156 - 0
app/src/main/java/com/joe/camera/webcam/ui/HomeScreen.kt

@@ -0,0 +1,156 @@
+package com.joe.camera.webcam.ui
+
+import android.annotation.SuppressLint
+import android.graphics.Color as AndroidColor
+import android.webkit.WebChromeClient
+import android.webkit.WebView
+import android.webkit.WebViewClient
+import android.app.Activity
+import android.content.Context
+import android.content.ContextWrapper
+import android.view.WindowManager
+import androidx.activity.compose.BackHandler
+import androidx.camera.core.CameraSelector
+import androidx.camera.view.PreviewView
+import androidx.compose.foundation.background
+import androidx.compose.foundation.gestures.detectDragGestures
+import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.WindowInsetsControllerCompat
+import com.joe.camera.webcam.MainUiState
+import com.joe.camera.webcam.MainViewModel
+import com.joe.camera.webcam.model.CameraState
+import kotlin.math.roundToInt
+
+private const val DARK_CSS = """
+body,.kr-mobile-layout,.home-tab-bar-item{background:#212121!important}
+.logo-container,.item-wrapper:has(.home-ad-flow-information),.interact-area,.float-app-button-wrp,.float-home-button-wrp,.article-praise,.may-like-art-wrp{display:none!important}
+.show-login{padding-top:1rem!important}.home-tab-bar-wrap .home-tab-bar-item.active{color:#206cff!important}
+.home-fixed-area-item-title,.item-title,.body-title,#body-content,#body-content blockquote{color:white!important}
+.home-tab-bar-item,.item-bar,.author-name,.time{color:grey!important}
+"""
+
+@SuppressLint("SetJavaScriptEnabled")
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun HomeScreen(viewModel: MainViewModel, state: MainUiState, onMedia: () -> Unit) {
+    val lifecycleOwner = LocalLifecycleOwner.current
+    var webView by remember { mutableStateOf<WebView?>(null) }
+    var progress by remember { mutableFloatStateOf(0f) }
+    var menuExpanded by remember { mutableStateOf(false) }
+    var settingsOpen by remember { mutableStateOf(false) }
+    var previewOpacity by remember { mutableFloatStateOf(0.02f) }
+    var previewWidth by remember { mutableFloatStateOf(80f) }
+    var previewX by remember { mutableFloatStateOf(100f) }
+    var previewY by remember { mutableFloatStateOf(200f) }
+    var buttonX by remember { mutableFloatStateOf(300f) }
+    var buttonY by remember { mutableFloatStateOf(580f) }
+    val density = LocalDensity.current
+    val activity = LocalContext.current.findActivity()
+
+    LaunchedEffect(state.blackScreen) {
+        activity?.window?.let { window ->
+            window.attributes = window.attributes.apply { screenBrightness = if (state.blackScreen) 0f else WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE }
+            WindowInsetsControllerCompat(window, window.decorView).apply {
+                if (state.blackScreen) hide(WindowInsetsCompat.Type.systemBars()) else show(WindowInsetsCompat.Type.systemBars())
+            }
+        }
+    }
+
+    BackHandler {
+        val view = webView
+        if (view?.canGoBack() == true) view.goBack() else view?.reload()
+    }
+
+    BoxWithConstraints(Modifier.fillMaxSize().background(Color(0xFF17171A))) {
+        val maxWidthPx = with(density) { maxWidth.toPx() }
+        val maxHeightPx = with(density) { maxHeight.toPx() }
+        AndroidView(
+            modifier = Modifier.fillMaxSize(),
+            factory = { context ->
+                WebView(context).apply {
+                    setBackgroundColor(AndroidColor.rgb(33, 33, 33))
+                    settings.javaScriptEnabled = true
+                    settings.domStorageEnabled = true
+                    webChromeClient = object : WebChromeClient() { override fun onProgressChanged(view: WebView?, newProgress: Int) { progress = newProgress / 100f } }
+                    webViewClient = object : WebViewClient() {
+                        override fun onPageFinished(view: WebView, url: String?) {
+                            val escaped = DARK_CSS.replace("`", "\\`")
+                            view.evaluateJavascript("document.getElementById('webcam-style')?.remove();const s=document.createElement('style');s.id='webcam-style';s.textContent=`$escaped`;document.head.appendChild(s);", null)
+                        }
+                    }
+                    loadUrl("https://www.36kr.com/")
+                    webView = this
+                }
+            },
+        )
+        if (progress in 0.001f..0.999f) LinearProgressIndicator(progress = { progress }, modifier = Modifier.fillMaxWidth().height(2.dp))
+
+        if (!state.remoteMode) {
+            AndroidView(
+                modifier = Modifier
+                    .offset { IntOffset(previewX.roundToInt(), previewY.roundToInt()) }
+                    .width(previewWidth.dp)
+                    .aspectRatio(9f / 16f)
+                    .alpha(previewOpacity)
+                    .clip(RoundedCornerShape((previewWidth / 9f).dp))
+                    .pointerInput(Unit) { detectDragGestures { change, drag -> change.consume(); previewX = (previewX + drag.x).coerceIn(0f, maxWidthPx - 20); previewY = (previewY + drag.y).coerceIn(0f, maxHeightPx - 20) } },
+                factory = { context -> PreviewView(context).also { viewModel.bindCamera(lifecycleOwner, it) } },
+            )
+        }
+
+        Box(Modifier.offset { IntOffset(buttonX.roundToInt(), buttonY.roundToInt()) }) {
+            Surface(
+                modifier = Modifier.size(42.dp)
+                    .pointerInput(Unit) { detectTapGestures(onTap = { viewModel.toggleRecording() }, onDoubleTap = { menuExpanded = !menuExpanded }, onLongPress = { settingsOpen = true }) }
+                    .pointerInput(Unit) { detectDragGestures { change, drag -> change.consume(); buttonX = (buttonX + drag.x).coerceIn(0f, maxWidthPx - 42.dp.toPx()); buttonY = (buttonY + drag.y).coerceIn(0f, maxHeightPx - 42.dp.toPx()) } },
+                shape = CircleShape,
+                color = Color.White,
+                shadowElevation = 8.dp,
+            ) { Icon(Icons.Default.Menu, "控制菜单", modifier = Modifier.padding(9.dp), tint = if (state.cameraState == CameraState.RECORDING || state.cameraState == CameraState.WORKING) Color(0xFF03A9F4) else Color.Black) }
+            DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) {
+                DropdownMenuItem(text = { Text("录像") }, leadingIcon = { Icon(Icons.Default.Videocam, null) }, onClick = { menuExpanded = false; viewModel.toggleRecording() })
+                DropdownMenuItem(text = { Text("拍照") }, leadingIcon = { Icon(Icons.Default.PhotoCamera, null) }, enabled = !state.remoteMode, onClick = { menuExpanded = false; viewModel.takePicture() })
+                DropdownMenuItem(text = { Text(if (state.previewPaused) "恢复预览" else "暂停预览") }, leadingIcon = { Icon(Icons.Default.Preview, null, tint = if (state.previewPaused) Color.Gray else Color(0xFF4CAF50)) }, enabled = !state.remoteMode, onClick = { menuExpanded = false; viewModel.togglePreview() })
+                DropdownMenuItem(text = { Text(if (state.remoteMode) "切换为本机" else "切换为遥控") }, leadingIcon = { Icon(Icons.Default.SettingsRemote, null, tint = if (state.remoteMode) Color(0xFF4CAF50) else Color.Gray) }, onClick = { menuExpanded = false; viewModel.toggleRemoteMode() })
+                DropdownMenuItem(text = { Text("媒体库") }, leadingIcon = { Icon(Icons.Default.Image, null) }, onClick = { menuExpanded = false; viewModel.loadMedia(); onMedia() })
+            }
+        }
+
+        if (state.blackScreen) Box(Modifier.fillMaxSize().background(Color.Black).pointerInput(Unit) { detectTapGestures(onLongPress = { viewModel.toggleBlackScreen() }) })
+    }
+
+    if (settingsOpen) ModalBottomSheet(onDismissRequest = { settingsOpen = false }) {
+        Column(Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 8.dp)) {
+            Text("预览透明度 ${(previewOpacity * 100).roundToInt()}%")
+            Slider(value = previewOpacity, onValueChange = { previewOpacity = it })
+            Text("预览宽度 ${previewWidth.roundToInt()} dp")
+            Slider(value = previewWidth, onValueChange = { previewWidth = it }, valueRange = 15f..360f)
+            Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
+                TextButton(onClick = { viewModel.switchLens(false) }) { Text("BACK") }
+                if (viewModel.hasFrontCamera()) TextButton(onClick = { viewModel.switchLens(true) }) { Text("FRONT") }
+            }
+            Spacer(Modifier.height(20.dp))
+        }
+    }
+}
+
+private tailrec fun Context.findActivity(): Activity? = when (this) { is Activity -> this; is ContextWrapper -> baseContext.findActivity(); else -> null }

+ 112 - 0
app/src/main/java/com/joe/camera/webcam/ui/MediaScreens.kt

@@ -0,0 +1,112 @@
+package com.joe.camera.webcam.ui
+
+import android.content.Context
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.background
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.grid.GridCells
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.foundation.lazy.grid.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.media3.common.MediaItem as ExoMediaItem
+import androidx.media3.exoplayer.ExoPlayer
+import androidx.media3.ui.PlayerView
+import coil.ImageLoader
+import coil.compose.AsyncImage
+import coil.decode.VideoFrameDecoder
+import coil.request.ImageRequest
+import coil.request.videoFrameMillis
+import com.joe.camera.webcam.MainViewModel
+import com.joe.camera.webcam.model.MediaItem
+import com.joe.camera.webcam.model.MediaKind
+import kotlinx.coroutines.delay
+
+@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
+@Composable
+fun MediaGridScreen(viewModel: MainViewModel, media: List<MediaItem>, onBack: () -> Unit, onOpen: (MediaItem) -> Unit) {
+    var deleteTarget by remember { mutableStateOf<MediaItem?>(null) }
+    LaunchedEffect(Unit) { viewModel.loadMedia() }
+    Scaffold(topBar = { TopAppBar(title = { Text("媒体库") }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Default.ArrowBack, "返回") } }) }) { padding ->
+        if (media.isEmpty()) Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { Text("Download/jf 中暂无媒体", color = Color.Gray) }
+        else LazyVerticalGrid(columns = GridCells.Fixed(3), modifier = Modifier.fillMaxSize().padding(padding), horizontalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
+            items(media, key = { "${it.kind}-${it.id}" }) { item ->
+                Box(Modifier.aspectRatio(1f).background(Color.DarkGray).combinedClickable(onClick = { onOpen(item) }, onLongClick = { viewModel.selectMedia(item); deleteTarget = item })) {
+                    MediaThumbnail(item, Modifier.fillMaxSize())
+                    if (item.kind == MediaKind.VIDEO) Icon(Icons.Default.PlayCircle, null, tint = Color.White, modifier = Modifier.align(Alignment.Center).size(42.dp))
+                }
+            }
+        }
+    }
+    deleteTarget?.let { item ->
+        AlertDialog(onDismissRequest = { deleteTarget = null }, title = { Text("删除文件") }, text = { Text(item.displayName) }, confirmButton = { TextButton(onClick = { viewModel.deleteSelected(); deleteTarget = null }) { Text("确定") } }, dismissButton = { TextButton(onClick = { deleteTarget = null }) { Text("取消") } })
+    }
+}
+
+@Composable
+private fun MediaThumbnail(item: MediaItem, modifier: Modifier = Modifier) {
+    val context = LocalContext.current
+    val imageLoader = remember(context) { ImageLoader.Builder(context).components { add(VideoFrameDecoder.Factory()) }.build() }
+    val request = remember(item.uri) { ImageRequest.Builder(context).data(item.uri).apply { if (item.kind == MediaKind.VIDEO) videoFrameMillis(0) }.crossfade(true).build() }
+    AsyncImage(model = request, imageLoader = imageLoader, contentDescription = item.displayName, contentScale = ContentScale.Crop, modifier = modifier)
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ImageViewerScreen(viewModel: MainViewModel, onBack: () -> Unit) {
+    val item = viewModel.selectedMedia ?: return onBack()
+    var confirmDelete by remember { mutableStateOf(false) }
+    Scaffold(topBar = { TopAppBar(title = { Text(item.displayName) }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Default.ArrowBack, "返回") } }, actions = { IconButton(onClick = { confirmDelete = true }) { Icon(Icons.Default.Delete, "删除") } }) }) { padding ->
+        AsyncImage(model = item.uri, contentDescription = item.displayName, contentScale = ContentScale.Fit, modifier = Modifier.fillMaxSize().padding(padding).background(Color(0xFF17171A)))
+    }
+    if (confirmDelete) DeleteDialog(item.displayName, onDismiss = { confirmDelete = false }, onConfirm = { if (viewModel.deleteSelected()) onBack(); confirmDelete = false })
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun VideoViewerScreen(viewModel: MainViewModel, onBack: () -> Unit) {
+    val item = viewModel.selectedMedia ?: return onBack()
+    val context = LocalContext.current
+    val player = remember(item.uri) { ExoPlayer.Builder(context).build().apply { setMediaItem(ExoMediaItem.fromUri(item.uri)); prepare() } }
+    var position by remember { mutableLongStateOf(0L) }
+    var duration by remember { mutableLongStateOf(1L) }
+    var playing by remember { mutableStateOf(false) }
+    var rotation by remember { mutableFloatStateOf(270f) }
+    var confirmDelete by remember { mutableStateOf(false) }
+    DisposableEffect(player) { onDispose { player.release() } }
+    LaunchedEffect(player) { while (true) { position = player.currentPosition.coerceAtLeast(0); duration = player.duration.coerceAtLeast(1); playing = player.isPlaying; delay(100) } }
+    Scaffold(topBar = { TopAppBar(title = { Text(item.displayName) }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Default.ArrowBack, "返回") } }, actions = { IconButton(onClick = { confirmDelete = true }) { Icon(Icons.Default.Delete, "删除") } }) }) { padding ->
+        Box(Modifier.fillMaxSize().padding(padding).background(Color(0xFF17171A))) {
+            AndroidView(factory = { PlayerView(it).apply { useController = false; this.player = player } }, update = { it.player = player }, modifier = Modifier.fillMaxSize().graphicsLayer { rotationZ = rotation })
+            Column(Modifier.align(Alignment.BottomCenter).fillMaxWidth().background(Color.Black.copy(alpha = 0.35f)).padding(bottom = 20.dp)) {
+                Row(verticalAlignment = Alignment.CenterVertically) {
+                    IconButton(onClick = { player.seekTo((player.currentPosition - 34).coerceAtLeast(0)) }) { Icon(Icons.Default.Remove, null) }
+                    Slider(value = position.toFloat().coerceIn(0f, duration.toFloat()), onValueChange = { position = it.toLong(); player.seekTo(position) }, valueRange = 0f..duration.toFloat(), modifier = Modifier.weight(1f))
+                    IconButton(onClick = { player.seekTo((player.currentPosition + 34).coerceAtMost(duration)) }) { Icon(Icons.Default.Add, null) }
+                }
+                Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically) {
+                    IconButton(onClick = { rotation -= 180f }) { Icon(Icons.Default.RotateLeft, null, modifier = Modifier.size(30.dp)) }
+                    IconButton(onClick = { if (player.isPlaying) player.pause() else player.play() }) { Icon(if (playing) Icons.Default.Pause else Icons.Default.PlayArrow, null, modifier = Modifier.size(48.dp)) }
+                    IconButton(onClick = { rotation += 180f }) { Icon(Icons.Default.RotateRight, null, modifier = Modifier.size(30.dp)) }
+                }
+            }
+        }
+    }
+    if (confirmDelete) DeleteDialog(item.displayName, onDismiss = { confirmDelete = false }, onConfirm = { player.release(); if (viewModel.deleteSelected()) onBack(); confirmDelete = false })
+}
+
+@Composable
+private fun DeleteDialog(name: String, onDismiss: () -> Unit, onConfirm: () -> Unit) {
+    AlertDialog(onDismissRequest = onDismiss, title = { Text("删除文件") }, text = { Text(name) }, confirmButton = { TextButton(onClick = onConfirm) { Text("确定") } }, dismissButton = { TextButton(onClick = onDismiss) { Text("取消") } })
+}

+ 10 - 0
app/src/main/java/com/joe/camera/webcam/ui/Theme.kt

@@ -0,0 +1,10 @@
+package com.joe.camera.webcam.ui
+
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+
+private val colors = darkColorScheme(primary = Color(0xFF03A9F4), secondary = Color(0xFF4CAF50), background = Color(0xFF17171A), surface = Color(0xFF212121), onBackground = Color.White)
+
+@Composable fun WebCamTheme(content: @Composable () -> Unit) { MaterialTheme(colorScheme = colors, content = content) }

+ 27 - 0
app/src/main/java/com/joe/camera/webcam/ui/WebCamApp.kt

@@ -0,0 +1,27 @@
+package com.joe.camera.webcam.ui
+
+import android.widget.Toast
+import androidx.compose.runtime.*
+import androidx.compose.ui.platform.LocalContext
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.rememberNavController
+import com.joe.camera.webcam.MainViewModel
+import com.joe.camera.webcam.model.MediaKind
+
+@Composable
+fun WebCamApp(viewModel: MainViewModel) {
+    WebCamTheme {
+        val nav = rememberNavController()
+        val state by viewModel.uiState.collectAsStateWithLifecycle()
+        val context = LocalContext.current
+        LaunchedEffect(state.message) { state.message?.let { Toast.makeText(context, it, Toast.LENGTH_SHORT).show(); viewModel.clearMessage() } }
+        NavHost(navController = nav, startDestination = "home") {
+            composable("home") { HomeScreen(viewModel, state, onMedia = { nav.navigate("media") }) }
+            composable("media") { MediaGridScreen(viewModel, state.media, onBack = nav::popBackStack, onOpen = { item -> viewModel.selectMedia(item); nav.navigate(if (item.kind == MediaKind.IMAGE) "image" else "video") }) }
+            composable("image") { ImageViewerScreen(viewModel, onBack = nav::popBackStack) }
+            composable("video") { VideoViewerScreen(viewModel, onBack = nav::popBackStack) }
+        }
+    }
+}

+ 3 - 0
app/src/main/res/drawable/ic_tile.xml

@@ -0,0 +1,3 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
+    <path android:fillColor="#FFFFFFFF" android:pathData="M4,6h3l2,-2h6l2,2h3a2,2 0,0 1,2 2v10a2,2 0,0 1,-2 2H4a2,2 0,0 1,-2 -2V8a2,2 0,0 1,2 -2M12,8a5,5 0,1 0,0 10,5 5,0 0,0 0,-10M12,10a3,3 0,1 1,0 6,3 3,0 0,1 0,-6"/>
+</vector>

+ 4 - 0
app/src/main/res/values/strings.xml

@@ -0,0 +1,4 @@
+<resources>
+    <string name="app_name">影</string>
+    <string name="tile_name">影</string>
+</resources>

+ 9 - 0
app/src/main/res/values/themes.xml

@@ -0,0 +1,9 @@
+<resources>
+    <style name="Theme.WebCam" parent="android:style/Theme.Material.Light.NoActionBar">
+        <item name="android:fontFamily">sans</item>
+        <item name="android:windowLightStatusBar">false</item>
+        <item name="android:statusBarColor">@android:color/transparent</item>
+        <item name="android:navigationBarColor">#17171A</item>
+        <item name="android:windowActionModeOverlay">true</item>
+    </style>
+</resources>

+ 19 - 0
app/src/test/java/com/joe/camera/webcam/data/MediaRepositoryTest.kt

@@ -0,0 +1,19 @@
+package com.joe.camera.webcam.data
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class MediaRepositoryTest {
+    @Test fun `media constants preserve disguised extensions and real mime types`() {
+        assertEquals("image/jpeg", MediaRepository.IMAGE_MIME)
+        assertEquals("video/mp4", MediaRepository.VIDEO_MIME)
+        assertTrue(MediaRepository.RELATIVE_DIR.endsWith("/jf"))
+    }
+
+    @Test fun `timestamp is sortable and filesystem safe`() {
+        val value = MediaRepository.timestamp()
+        assertEquals(17, value.length)
+        assertTrue(value.all(Char::isDigit))
+    }
+}

+ 20 - 0
app/src/test/java/com/joe/camera/webcam/model/AppModelsTest.kt

@@ -0,0 +1,20 @@
+package com.joe.camera.webcam.model
+
+import org.junit.Assert.*
+import org.junit.Test
+
+class AppModelsTest {
+    @Test fun `BLE command codes remain compatible with Flutter app`() {
+        assertEquals(RemoteCommand.VOLUME_UP, RemoteCommand.fromCode(0))
+        assertEquals(RemoteCommand.VOLUME_DOWN, RemoteCommand.fromCode(1))
+        assertEquals(RemoteCommand.RECORD_VIDEO, RemoteCommand.fromCode(2))
+        assertEquals(RemoteCommand.OPEN_SETTING, RemoteCommand.fromCode(3))
+        assertNull(RemoteCommand.fromCode(9))
+    }
+
+    @Test fun `camera wire state maps busy and free`() {
+        assertEquals(CameraState.FREE, CameraState.fromWire(0))
+        assertEquals(CameraState.WORKING, CameraState.fromWire(1))
+        assertEquals(1, CameraState.RECORDING.wireCode.toInt())
+    }
+}

+ 5 - 0
build.gradle.kts

@@ -0,0 +1,5 @@
+plugins {
+    id("com.android.application") version "8.7.3" apply false
+    id("org.jetbrains.kotlin.android") version "2.0.21" apply false
+    id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false
+}

+ 4 - 0
gradle.properties

@@ -0,0 +1,4 @@
+org.gradle.jvmargs=-Xmx3072m -Dfile.encoding=UTF-8
+android.useAndroidX=true
+kotlin.code.style=official
+android.nonTransitiveRClass=true

BIN
gradle/wrapper/gradle-wrapper.jar


+ 7 - 0
gradle/wrapper/gradle-wrapper.properties

@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists

+ 160 - 0
gradlew

@@ -0,0 +1,160 @@
+#!/usr/bin/env bash
+
+##############################################################################
+##
+##  Gradle start up script for UN*X
+##
+##############################################################################
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS=""
+
+APP_NAME="Gradle"
+APP_BASE_NAME=`basename "$0"`
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD="maximum"
+
+warn ( ) {
+    echo "$*"
+}
+
+die ( ) {
+    echo
+    echo "$*"
+    echo
+    exit 1
+}
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+case "`uname`" in
+  CYGWIN* )
+    cygwin=true
+    ;;
+  Darwin* )
+    darwin=true
+    ;;
+  MINGW* )
+    msys=true
+    ;;
+esac
+
+# Attempt to set APP_HOME
+# Resolve links: $0 may be a link
+PRG="$0"
+# Need this for relative symlinks.
+while [ -h "$PRG" ] ; do
+    ls=`ls -ld "$PRG"`
+    link=`expr "$ls" : '.*-> \(.*\)$'`
+    if expr "$link" : '/.*' > /dev/null; then
+        PRG="$link"
+    else
+        PRG=`dirname "$PRG"`"/$link"
+    fi
+done
+SAVED="`pwd`"
+cd "`dirname \"$PRG\"`/" >/dev/null
+APP_HOME="`pwd -P`"
+cd "$SAVED" >/dev/null
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+    if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+        # IBM's JDK on AIX uses strange locations for the executables
+        JAVACMD="$JAVA_HOME/jre/sh/java"
+    else
+        JAVACMD="$JAVA_HOME/bin/java"
+    fi
+    if [ ! -x "$JAVACMD" ] ; then
+        die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+    fi
+else
+    JAVACMD="java"
+    which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then
+    MAX_FD_LIMIT=`ulimit -H -n`
+    if [ $? -eq 0 ] ; then
+        if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
+            MAX_FD="$MAX_FD_LIMIT"
+        fi
+        ulimit -n $MAX_FD
+        if [ $? -ne 0 ] ; then
+            warn "Could not set maximum file descriptor limit: $MAX_FD"
+        fi
+    else
+        warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
+    fi
+fi
+
+# For Darwin, add options to specify how the application appears in the dock
+if $darwin; then
+    GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
+fi
+
+# For Cygwin, switch paths to Windows format before running java
+if $cygwin ; then
+    APP_HOME=`cygpath --path --mixed "$APP_HOME"`
+    CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
+    JAVACMD=`cygpath --unix "$JAVACMD"`
+
+    # We build the pattern for arguments to be converted via cygpath
+    ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
+    SEP=""
+    for dir in $ROOTDIRSRAW ; do
+        ROOTDIRS="$ROOTDIRS$SEP$dir"
+        SEP="|"
+    done
+    OURCYGPATTERN="(^($ROOTDIRS))"
+    # Add a user-defined pattern to the cygpath arguments
+    if [ "$GRADLE_CYGPATTERN" != "" ] ; then
+        OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
+    fi
+    # Now convert the arguments - kludge to limit ourselves to /bin/sh
+    i=0
+    for arg in "$@" ; do
+        CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
+        CHECK2=`echo "$arg"|egrep -c "^-"`                                 ### Determine if an option
+
+        if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then                    ### Added a condition
+            eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
+        else
+            eval `echo args$i`="\"$arg\""
+        fi
+        i=$((i+1))
+    done
+    case $i in
+        (0) set -- ;;
+        (1) set -- "$args0" ;;
+        (2) set -- "$args0" "$args1" ;;
+        (3) set -- "$args0" "$args1" "$args2" ;;
+        (4) set -- "$args0" "$args1" "$args2" "$args3" ;;
+        (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
+        (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
+        (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
+        (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
+        (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
+    esac
+fi
+
+# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules
+function splitJvmOpts() {
+    JVM_OPTS=("$@")
+}
+eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS
+JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME"
+
+exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"

+ 90 - 0
gradlew.bat

@@ -0,0 +1,90 @@
+@if "%DEBUG%" == "" @echo off
+@rem ##########################################################################
+@rem
+@rem  Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS=
+
+set DIRNAME=%~dp0
+if "%DIRNAME%" == "" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if "%ERRORLEVEL%" == "0" goto init
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto init
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:init
+@rem Get command-line arguments, handling Windowz variants
+
+if not "%OS%" == "Windows_NT" goto win9xME_args
+if "%@eval[2+2]" == "4" goto 4NT_args
+
+:win9xME_args
+@rem Slurp the command line arguments.
+set CMD_LINE_ARGS=
+set _SKIP=2
+
+:win9xME_args_slurp
+if "x%~1" == "x" goto execute
+
+set CMD_LINE_ARGS=%*
+goto execute
+
+:4NT_args
+@rem Get arguments from the 4NT Shell from JP Software
+set CMD_LINE_ARGS=%$
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
+
+:end
+@rem End local scope for the variables with windows NT shell
+if "%ERRORLEVEL%"=="0" goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+if  not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
+exit /b 1
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega

+ 18 - 0
settings.gradle.kts

@@ -0,0 +1,18 @@
+pluginManagement {
+    repositories {
+        google()
+        mavenCentral()
+        gradlePluginPortal()
+    }
+}
+
+dependencyResolutionManagement {
+    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+    repositories {
+        google()
+        mavenCentral()
+    }
+}
+
+rootProject.name = "WebCam"
+include(":app")