commit 797c5c48d7cb4f28a0ced3a2e6af9882c76f8969 Author: srtk Date: Fri Nov 21 21:37:05 2025 +0530 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..80aa9e5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties +debug.keystore diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml new file mode 100644 index 0000000..4a53bee --- /dev/null +++ b/.idea/AndroidProjectSystem.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml new file mode 100644 index 0000000..7643783 --- /dev/null +++ b/.idea/codeStyles/Project.xml @@ -0,0 +1,123 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000..79ee123 --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..b86273d --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml new file mode 100644 index 0000000..b268ef3 --- /dev/null +++ b/.idea/deploymentTargetSelector.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..639c779 --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..f0c6ad0 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,50 @@ + + + + \ No newline at end of file diff --git a/.idea/migrations.xml b/.idea/migrations.xml new file mode 100644 index 0000000..f8051a6 --- /dev/null +++ b/.idea/migrations.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..b2c751a --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml new file mode 100644 index 0000000..16660f1 --- /dev/null +++ b/.idea/runConfigurations.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..0d39b47 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,62 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) +} + +android { + namespace = "com.srtk.airlink" + compileSdk { + version = release(36) + } + + defaultConfig { + applicationId = "com.srtk.airlink" + minSdk = 26 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = "11" + } + buildFeatures { + compose = true + } +} + +dependencies { + 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) + implementation("androidx.compose.material:material-icons-extended:1.7.8") + 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) + +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# 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 \ No newline at end of file diff --git a/app/src/androidTest/java/com/srtk/airlink/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/srtk/airlink/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..4ddfbec --- /dev/null +++ b/app/src/androidTest/java/com/srtk/airlink/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.srtk.airlink + +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.srtk.webcam", appContext.packageName) + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..f519b2c --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + \ 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 100644 index 0000000..51db306 Binary files /dev/null and b/app/src/main/ic_launcher-playstore.png differ diff --git a/app/src/main/java/com/srtk/airlink/CameraDiagnostics.kt b/app/src/main/java/com/srtk/airlink/CameraDiagnostics.kt new file mode 100644 index 0000000..76e7827 --- /dev/null +++ b/app/src/main/java/com/srtk/airlink/CameraDiagnostics.kt @@ -0,0 +1,114 @@ +package com.srtk.airlink + +import android.content.Context +import android.hardware.camera2.* +import android.media.MediaCodec +import android.util.Log +import android.util.Size + +object CameraDiagnostics { + + fun printAllCapabilities(context: Context) { + val manager = context.getSystemService(Context.CAMERA_SERVICE) as CameraManager + + println("\n========== CAMERA DIAGNOSTICS ==========\n") + + for (cameraId in manager.cameraIdList) { + val characteristics = manager.getCameraCharacteristics(cameraId) + val facing = characteristics.get(CameraCharacteristics.LENS_FACING) + val facingStr = when(facing) { + CameraCharacteristics.LENS_FACING_BACK -> "BACK" + CameraCharacteristics.LENS_FACING_FRONT -> "FRONT" + else -> "EXTERNAL" + } + + println("📷 Camera $cameraId ($facingStr)") + println("=" .repeat(50)) + + // Supported output sizes for MediaCodec + val map = characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP) + val codecSizes = map?.getOutputSizes(MediaCodec::class.java) ?: emptyArray() + + println("\n🎥 Supported Video Resolutions (${codecSizes.size} total):") + codecSizes + .sortedByDescending { it.width * it.height } + .forEach { size -> + val aspectRatio = String.format("%.2f", size.width.toFloat() / size.height) + val mpx = String.format("%.1f", (size.width * size.height) / 1_000_000f) + println(" • ${size.width}x${size.height} ($mpx MP, $aspectRatio:1)") + } + + // FPS Ranges + val fpsRanges = characteristics.get( + CameraCharacteristics.CONTROL_AE_AVAILABLE_TARGET_FPS_RANGES + ) ?: emptyArray() + + println("\n📊 Supported FPS Ranges:") + fpsRanges.forEach { range -> + println(" • ${range.lower}-${range.upper} fps") + } + + // Max digital zoom + val maxZoom = characteristics.get( + CameraCharacteristics.SCALER_AVAILABLE_MAX_DIGITAL_ZOOM + ) ?: 1f + println("\n🔍 Max Zoom: ${maxZoom}x") + + // Flash + val hasFlash = characteristics.get( + CameraCharacteristics.FLASH_INFO_AVAILABLE + ) == true + println("⚡ Flash: ${if(hasFlash) "Yes" else "No"}") + + // Stabilization + val stabilizationModes = characteristics.get( + CameraCharacteristics.CONTROL_AVAILABLE_VIDEO_STABILIZATION_MODES + ) ?: intArrayOf() + val hasStab = CameraMetadata.CONTROL_VIDEO_STABILIZATION_MODE_ON in stabilizationModes + println("📹 Video Stabilization: ${if(hasStab) "Yes" else "No"}") + + // Manual focus + val minFocusDist = characteristics.get( + CameraCharacteristics.LENS_INFO_MINIMUM_FOCUS_DISTANCE + ) ?: 0f + println("🎯 Manual Focus: ${if(minFocusDist > 0) "Yes" else "No"}") + + // Effects + val effects = characteristics.get( + CameraCharacteristics.CONTROL_AVAILABLE_EFFECTS + ) ?: intArrayOf() + println("\n🎨 Supported Effects:") + effects.forEach { effect -> + val name = when(effect) { + CameraMetadata.CONTROL_EFFECT_MODE_OFF -> "None" + CameraMetadata.CONTROL_EFFECT_MODE_MONO -> "Mono" + CameraMetadata.CONTROL_EFFECT_MODE_NEGATIVE -> "Negative" + CameraMetadata.CONTROL_EFFECT_MODE_SEPIA -> "Sepia" + CameraMetadata.CONTROL_EFFECT_MODE_AQUA -> "Aqua" + else -> "Unknown($effect)" + } + println(" • $name") + } + + // White Balance + val wbModes = characteristics.get( + CameraCharacteristics.CONTROL_AWB_AVAILABLE_MODES + ) ?: intArrayOf() + println("\n☀️ Supported White Balance:") + wbModes.forEach { mode -> + val name = when(mode) { + CameraMetadata.CONTROL_AWB_MODE_AUTO -> "Auto" + CameraMetadata.CONTROL_AWB_MODE_DAYLIGHT -> "Daylight" + CameraMetadata.CONTROL_AWB_MODE_CLOUDY_DAYLIGHT -> "Cloudy" + CameraMetadata.CONTROL_AWB_MODE_INCANDESCENT -> "Warm" + CameraMetadata.CONTROL_AWB_MODE_FLUORESCENT -> "Cool" + CameraMetadata.CONTROL_AWB_MODE_SHADE -> "Shade" + else -> "Unknown($mode)" + } + println(" • $name") + } + + println("\n" + "=".repeat(50) + "\n") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/srtk/airlink/CameraStreamer.kt b/app/src/main/java/com/srtk/airlink/CameraStreamer.kt new file mode 100644 index 0000000..7c2e8d2 --- /dev/null +++ b/app/src/main/java/com/srtk/airlink/CameraStreamer.kt @@ -0,0 +1,550 @@ +package com.srtk.airlink + +import android.annotation.SuppressLint +import android.content.Context +import android.hardware.camera2.* +import android.media.MediaCodec +import android.media.MediaCodecInfo +import android.media.MediaFormat +import android.os.Bundle +import android.os.Handler +import android.os.HandlerThread +import android.util.Log +import android.util.Range +import android.view.Surface +import java.net.DatagramPacket +import java.net.DatagramSocket +import java.net.InetAddress +import java.util.concurrent.Semaphore +import java.util.concurrent.TimeUnit +import kotlin.math.abs +import kotlin.math.min + +data class VideoConfig(val width: Int, val height: Int, val fps: Int) { + override fun toString() = "${height}p" + fun toDetailedString() = "${height}p (${width}×${height} @ ${fps}fps)" +} + +data class CameraCapabilities( + val supportedConfigs: List, + val maxZoom: Float, + val hasFlash: Boolean, + val hasFrontCamera: Boolean, + val supportedWhiteBalance: List, + val focusRange: Range?, + val exposureRange: Range? +) + +enum class WhiteBalance(val value: Int, val label: String) { + AUTO(CameraMetadata.CONTROL_AWB_MODE_AUTO, "Auto"), + WARM(CameraMetadata.CONTROL_AWB_MODE_INCANDESCENT, "Warm"), + COOL(CameraMetadata.CONTROL_AWB_MODE_FLUORESCENT, "Cool") +} + +class CameraStreamer(private val context: Context) { + private val TAG = "CameraStreamer" + private val manager = context.getSystemService(Context.CAMERA_SERVICE) as CameraManager + + private val cameraThread = HandlerThread("CamThread").apply { start() } + private val camHandler = Handler(cameraThread.looper) + + private var cameraDevice: CameraDevice? = null + private var captureSession: CameraCaptureSession? = null + private var requestBuilder: CaptureRequest.Builder? = null + private var mediaCodec: MediaCodec? = null + private var socket: DatagramSocket? = null + + @Volatile private var isStreaming = false + private val restartLock = Semaphore(1) + private var currentConfig = VideoConfig(960, 720, 30) + private var bitrate = 3000000 + + private var zoomRatio = 1f + private var flashMode = false + private var wbMode = CameraMetadata.CONTROL_AWB_MODE_AUTO + private var focusMode = CameraMetadata.CONTROL_AF_MODE_CONTINUOUS_VIDEO + private var focusDistance = 0f + private var exposureCompensation = 0 + private var onErrorCallback: ((String) -> Unit)? = null + private var onDisconnectedCallback: (() -> Unit)? = null + + private var lastPacketTime = 0L + private val connectionTimeout = 5000L // 5 seconds + + fun setErrorCallback(callback: (String) -> Unit) { + onErrorCallback = callback + } + + fun setDisconnectedCallback(callback: () -> Unit) { + onDisconnectedCallback = callback + } + + // Test if a resolution actually works with the encoder + private fun testEncoderResolution(width: Int, height: Int): Boolean { + var testCodec: MediaCodec? = null + try { + val format = MediaFormat.createVideoFormat( + MediaFormat.MIMETYPE_VIDEO_AVC, + width, + height + ).apply { + setInteger( + MediaFormat.KEY_COLOR_FORMAT, + MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface + ) + setInteger(MediaFormat.KEY_BIT_RATE, 2000000) + setInteger(MediaFormat.KEY_FRAME_RATE, 30) + setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 2) + } + + testCodec = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_VIDEO_AVC) + testCodec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) + testCodec.release() + + Log.d(TAG, "✅ Resolution ${width}x${height} WORKS") + return true + } catch (e: Exception) { + Log.d(TAG, "❌ Resolution ${width}x${height} FAILED: ${e.message}") + return false + } finally { + try { + testCodec?.release() + } catch (e: Exception) {} + } + } + + private fun is16by9(width: Int, height: Int): Boolean { + val ratio = width.toDouble() / height.toDouble() + return abs(ratio - 16.0 / 9.0) < 0.1 + } + + private fun is4by3(width: Int, height: Int): Boolean { + val ratio = width.toDouble() / height.toDouble() + return abs(ratio - 4.0 / 3.0) < 0.1 + } + + fun getCameraCapabilities(useFront: Boolean): CameraCapabilities { + val camId = getCameraId(useFront) + val chars = manager.getCameraCharacteristics(camId) + val map = chars.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP)!! + + val allSizes = map.getOutputSizes(MediaCodec::class.java) + .filter { is16by9(it.width, it.height) || is4by3(it.width, it.height) } + .sortedByDescending { it.width * it.height } + + Log.d(TAG, "Testing encoder capabilities...") + + val configs = mutableListOf() + val fpsRanges = chars.get( + CameraCharacteristics.CONTROL_AE_AVAILABLE_TARGET_FPS_RANGES + ) ?: emptyArray() + + for (size in allSizes) { + try { + // TEST if encoder actually supports this resolution + if (!testEncoderResolution(size.width, size.height)) { + continue + } + + val minFrameDuration = map.getOutputMinFrameDuration( + MediaCodec::class.java, + size + ) + + if (minFrameDuration <= 0) continue + + val maxFpsFromDuration = (1_000_000_000.0 / minFrameDuration).toInt() + val validRanges = fpsRanges.filter { + it.upper <= maxFpsFromDuration && it.upper >= 15 + } + + val targetFps = min(validRanges.maxOfOrNull { it.upper } ?: 30, 30) + + if (targetFps >= 15) { + configs.add(VideoConfig(size.width, size.height, targetFps)) + } + } catch (e: Exception) { + Log.w(TAG, "Skipping ${size.width}x${size.height}: ${e.message}") + } + } + + if (configs.isEmpty()) { + configs.add(VideoConfig(640, 480, 30)) + Log.w(TAG, "No configs found, added 640x480 fallback") + } + + val maxZoom = chars.get(CameraCharacteristics.SCALER_AVAILABLE_MAX_DIGITAL_ZOOM) ?: 1f + val hasFlash = chars.get(CameraCharacteristics.FLASH_INFO_AVAILABLE) == true + val hasFront = manager.cameraIdList.any { + manager.getCameraCharacteristics(it).get(CameraCharacteristics.LENS_FACING) == + CameraCharacteristics.LENS_FACING_FRONT + } + + val minFocus = chars.get(CameraCharacteristics.LENS_INFO_MINIMUM_FOCUS_DISTANCE) + val exposureRange = chars.get(CameraCharacteristics.CONTROL_AE_COMPENSATION_RANGE) + + return CameraCapabilities( + configs.sortedByDescending { it.width * it.height }, + maxZoom, + hasFlash, + hasFront, + listOf(WhiteBalance.AUTO, WhiteBalance.WARM, WhiteBalance.COOL), + if (minFocus != null && minFocus > 0) Range(0f, minFocus) else null, + exposureRange + ) + } + + fun start( + ip: InetAddress, + port: Int, + config: VideoConfig, + useFront: Boolean + ) { + currentConfig = config + val camId = getCameraId(useFront) + + camHandler.post { + try { + if (restartLock.tryAcquire(3, TimeUnit.SECONDS)) { + try { + stopInternal() + Thread.sleep(500) + startInternal(ip, port, camId) + } finally { + restartLock.release() + } + } + } catch (e: Exception) { + Log.e(TAG, "Start error", e) + onErrorCallback?.invoke("Failed to start: ${e.message}") + } + } + } + + fun updateBitrate(mbps: Int) { + val newBitrate = mbps * 1_000_000 + if (bitrate == newBitrate) return + + bitrate = newBitrate + + camHandler.post { + try { + mediaCodec?.let { codec -> + val bundle = Bundle().apply { + putInt(MediaCodec.PARAMETER_KEY_VIDEO_BITRATE, bitrate) + } + codec.setParameters(bundle) + Log.d(TAG, "Bitrate updated to ${mbps} Mbps") + } + } catch (e: Exception) { + Log.e(TAG, "Bitrate update failed", e) + } + } + } + + fun setZoom(z: Float) { + zoomRatio = z + updateSession() + } + + fun setTorch(on: Boolean) { + flashMode = on + updateSession() + } + + fun setWhiteBalance(wb: WhiteBalance) { + wbMode = wb.value + updateSession() + } + + fun setFocusMode(auto: Boolean) { + focusMode = if (auto) CameraMetadata.CONTROL_AF_MODE_CONTINUOUS_VIDEO + else CameraMetadata.CONTROL_AF_MODE_OFF + updateSession() + } + + fun setFocusDistance(distance: Float) { + focusDistance = distance + focusMode = CameraMetadata.CONTROL_AF_MODE_OFF + updateSession() + } + + fun setExposure(value: Int) { + exposureCompensation = value + updateSession() + } + + @SuppressLint("MissingPermission") + private fun startInternal( + ip: InetAddress, + port: Int, + camId: String + ) { + try { + socket = DatagramSocket() + isStreaming = true + lastPacketTime = System.currentTimeMillis() + + val width = currentConfig.width + val height = currentConfig.height + + val pixelCount = width * height + val calculatedBitrate = when { + pixelCount >= 1920 * 1080 -> 8_000_000 + pixelCount >= 1280 * 720 -> 5_000_000 + pixelCount >= 640 * 480 -> 2_500_000 + else -> 1_500_000 + } + + Log.d(TAG, "Starting: ${width}x${height} @ ${currentConfig.fps}fps") + + val format = MediaFormat.createVideoFormat( + MediaFormat.MIMETYPE_VIDEO_AVC, + width, + height + ).apply { + setInteger( + MediaFormat.KEY_COLOR_FORMAT, + MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface + ) + setInteger(MediaFormat.KEY_BIT_RATE, calculatedBitrate) + setInteger(MediaFormat.KEY_FRAME_RATE, currentConfig.fps) + setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 2) + } + + mediaCodec = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_VIDEO_AVC) + mediaCodec?.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) + + val surface = mediaCodec?.createInputSurface() + ?: throw Exception("Failed to create input surface") + + mediaCodec?.start() + + Log.d(TAG, "✅ Encoder started with ${width}x${height}") + + // Start connection monitor + Thread { + while (isStreaming) { + Thread.sleep(1000) + if (System.currentTimeMillis() - lastPacketTime > connectionTimeout) { + Log.w(TAG, "Server disconnected - no data sent") + stop() + onDisconnectedCallback?.invoke() + break + } + } + }.start() + + Thread { + Thread.currentThread().priority = Thread.MAX_PRIORITY + streamLoop(ip, port) + }.start() + + manager.openCamera( + camId, + object : CameraDevice.StateCallback() { + override fun onOpened(camera: CameraDevice) { + cameraDevice = camera + createSession(camera, surface) + } + + override fun onDisconnected(camera: CameraDevice) { + Log.w(TAG, "Camera disconnected") + stopInternal() + } + + override fun onError(camera: CameraDevice, error: Int) { + Log.e(TAG, "Camera error: $error") + stopInternal() + onErrorCallback?.invoke("Camera error: $error") + } + }, + camHandler + ) + } catch (e: Exception) { + Log.e(TAG, "Start failed: ${e.message}", e) + onErrorCallback?.invoke("Failed to start: ${e.message}") + stopInternal() + } + } + + private fun createSession(camera: CameraDevice, surface: Surface) { + try { + camera.createCaptureSession( + listOf(surface), + object : CameraCaptureSession.StateCallback() { + override fun onConfigured(session: CameraCaptureSession) { + if (!isStreaming) return + + captureSession = session + requestBuilder = camera.createCaptureRequest( + CameraDevice.TEMPLATE_RECORD + ).apply { + addTarget(surface) + set(CaptureRequest.CONTROL_MODE, CameraMetadata.CONTROL_MODE_AUTO) + set(CaptureRequest.CONTROL_AE_MODE, CameraMetadata.CONTROL_AE_MODE_ON) + set(CaptureRequest.CONTROL_AE_TARGET_FPS_RANGE, + Range(currentConfig.fps, currentConfig.fps)) + set(CaptureRequest.CONTROL_AF_MODE, focusMode) + set(CaptureRequest.CONTROL_VIDEO_STABILIZATION_MODE, + CameraMetadata.CONTROL_VIDEO_STABILIZATION_MODE_ON) + + if (focusMode == CameraMetadata.CONTROL_AF_MODE_OFF) { + set(CaptureRequest.LENS_FOCUS_DISTANCE, focusDistance) + } + set(CaptureRequest.CONTROL_AE_EXPOSURE_COMPENSATION, exposureCompensation) + } + updateSession() + Log.d(TAG, "Camera session started") + } + + override fun onConfigureFailed(session: CameraCaptureSession) { + Log.e(TAG, "Session config failed") + onErrorCallback?.invoke("Camera session failed") + } + }, + camHandler + ) + } catch (e: Exception) { + Log.e(TAG, "Create session error", e) + } + } + + private fun updateSession() { + val builder = requestBuilder ?: return + val session = captureSession ?: return + try { + if (zoomRatio > 1f) { + builder.set(CaptureRequest.CONTROL_ZOOM_RATIO, zoomRatio) + } + builder.set( + CaptureRequest.FLASH_MODE, + if (flashMode) CameraMetadata.FLASH_MODE_TORCH + else CameraMetadata.FLASH_MODE_OFF + ) + builder.set(CaptureRequest.CONTROL_AWB_MODE, wbMode) + builder.set(CaptureRequest.CONTROL_AF_MODE, focusMode) + + if (focusMode == CameraMetadata.CONTROL_AF_MODE_OFF) { + builder.set(CaptureRequest.LENS_FOCUS_DISTANCE, focusDistance) + } + + builder.set(CaptureRequest.CONTROL_AE_EXPOSURE_COMPENSATION, exposureCompensation) + + session.setRepeatingRequest(builder.build(), null, camHandler) + } catch (e: Exception) { + Log.e(TAG, "Update session error", e) + } + } + + private fun streamLoop(ip: InetAddress, port: Int) { + val bufferInfo = MediaCodec.BufferInfo() + val maxChunkSize = 1400 + + Log.d(TAG, "Stream loop started") + + try { + while (isStreaming) { + val codec = mediaCodec ?: break + + val index = codec.dequeueOutputBuffer(bufferInfo, 10000) + + when (index) { + MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> { + val format = codec.outputFormat + Log.d(TAG, "Output format: $format") + } + + in 0..Int.MAX_VALUE -> { + try { + if (bufferInfo.size <= 0) { + codec.releaseOutputBuffer(index, false) + continue + } + + val buffer = codec.getOutputBuffer(index) ?: continue + buffer.position(bufferInfo.offset) + buffer.limit(bufferInfo.offset + bufferInfo.size) + + val frameData = ByteArray(bufferInfo.size) + buffer.get(frameData) + + var offset = 0 + while (offset < frameData.size && isStreaming) { + val chunkSize = min(maxChunkSize, frameData.size - offset) + socket?.send( + DatagramPacket(frameData, offset, chunkSize, ip, port) + ) + offset += chunkSize + } + + lastPacketTime = System.currentTimeMillis() + codec.releaseOutputBuffer(index, false) + } catch (e: Exception) { + Log.e(TAG, "Buffer processing error", e) + } + } + } + } + } catch (e: Exception) { + Log.e(TAG, "Stream loop error", e) + } + + Log.d(TAG, "Stream loop ended") + } + + fun stop() { + camHandler.post { stopInternal() } + } + + fun cleanup() { + stop() + cameraThread.quitSafely() + } + + private fun stopInternal() { + isStreaming = false + try { + captureSession?.stopRepeating() + captureSession?.close() + captureSession = null + } catch (e: Exception) { + Log.e(TAG, "Session stop error", e) + } + + try { + cameraDevice?.close() + cameraDevice = null + } catch (e: Exception) { + Log.e(TAG, "Camera close error", e) + } + + try { + mediaCodec?.stop() + mediaCodec?.release() + mediaCodec = null + } catch (e: Exception) { + Log.e(TAG, "Codec stop error", e) + } + + try { + socket?.close() + socket = null + } catch (e: Exception) { + Log.e(TAG, "Socket close error", e) + } + + requestBuilder = null + } + + private fun getCameraId(front: Boolean): String { + val target = if (front) + CameraCharacteristics.LENS_FACING_FRONT + else + CameraCharacteristics.LENS_FACING_BACK + return manager.cameraIdList.firstOrNull { + manager.getCameraCharacteristics(it) + .get(CameraCharacteristics.LENS_FACING) == target + } ?: manager.cameraIdList[0] + } +} \ No newline at end of file diff --git a/app/src/main/java/com/srtk/airlink/MainActivity.kt b/app/src/main/java/com/srtk/airlink/MainActivity.kt new file mode 100644 index 0000000..4c3e402 --- /dev/null +++ b/app/src/main/java/com/srtk/airlink/MainActivity.kt @@ -0,0 +1,1284 @@ +package com.srtk.airlink + +import android.Manifest +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.BackHandler +import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.* +import androidx.compose.foundation.* +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.material.icons.outlined.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.delay +import java.net.InetAddress + +class MainActivity : ComponentActivity() { + private lateinit var streamer: CameraStreamer + private lateinit var discovery: DiscoveryManager + + private val permissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { permissions -> + if (!permissions.values.all { it }) finish() + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + streamer = CameraStreamer(this) + discovery = DiscoveryManager(this) + + permissionLauncher.launch( + arrayOf( + Manifest.permission.CAMERA, + Manifest.permission.RECORD_AUDIO + ) + ) + + setContent { + AirLinkTheme { + AppNavigation(discovery, streamer) + } + } + } + + override fun onDestroy() { + super.onDestroy() + streamer.cleanup() + } +} + +// Premium Blue-Cyan Color Palette matching the icon +private val DarkBg = Color(0xFF0A0E27) +private val SurfaceBg = Color(0xFF141B3C) +private val CardBg = Color(0xFF1C2447) +private val CardBgLight = Color(0xFF232D54) +private val AccentBlue = Color(0xFF5B9FE3) // Primary blue from icon +private val AccentCyan = Color(0xFF60D5DD) // Cyan from icon +private val AccentGreen = Color(0xFF34C759) // Success green +private val AccentRed = Color(0xFFFF3B30) // Error red +private val AccentOrange = Color(0xFFFF9500) // Warning orange +private val TextPrimary = Color(0xFFFFFFFF) +private val TextSecondary = Color(0xFF8E96B7) +private val DividerColor = Color(0xFF2A3558) +private val OverlayBg = Color(0xFF0F1429) + +@Composable +fun AirLinkTheme(content: @Composable () -> Unit) { + MaterialTheme( + colorScheme = darkColorScheme( + background = DarkBg, + surface = SurfaceBg, + primary = AccentBlue, + secondary = AccentCyan, + tertiary = AccentGreen, + error = AccentRed + ), + typography = Typography( + displayLarge = MaterialTheme.typography.displayLarge.copy(fontWeight = FontWeight.Bold), + displayMedium = MaterialTheme.typography.displayMedium.copy(fontWeight = FontWeight.Bold), + headlineLarge = MaterialTheme.typography.headlineLarge.copy(fontWeight = FontWeight.Bold), + titleLarge = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold), + titleMedium = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), + bodyLarge = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Normal), + ), + content = content + ) +} + +enum class Screen { SCANNING, DASHBOARD } + +@OptIn(ExperimentalAnimationApi::class) +@Composable +fun AppNavigation(discovery: DiscoveryManager, streamer: CameraStreamer) { + var currentScreen by remember { mutableStateOf(Screen.SCANNING) } + var selectedServer by remember { mutableStateOf(null) } + var isStreaming by remember { mutableStateOf(false) } + + BackHandler(enabled = currentScreen == Screen.DASHBOARD) { + if (isStreaming) streamer.stop() + isStreaming = false + currentScreen = Screen.SCANNING + } + + AnimatedContent( + targetState = currentScreen, + label = "ScreenTransition", + transitionSpec = { + slideInHorizontally { it } + fadeIn() with + slideOutHorizontally { -it } + fadeOut() + } + ) { screen -> + when (screen) { + Screen.SCANNING -> ScanScreen( + discovery = discovery, + onServerSelected = { server -> + selectedServer = server + currentScreen = Screen.DASHBOARD + } + ) + + Screen.DASHBOARD -> DashboardScreen( + streamer = streamer, + server = selectedServer!!, + isStreaming = isStreaming, + onStreamingChange = { isStreaming = it }, + onBack = { + if (isStreaming) streamer.stop() + isStreaming = false + currentScreen = Screen.SCANNING + } + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ScanScreen( + discovery: DiscoveryManager, + onServerSelected: (ServerInfo) -> Unit +) { + var servers by remember { mutableStateOf(listOf()) } + var showManualDialog by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + discovery.discover().collect { server -> + if (!servers.contains(server)) { + servers = servers + server + } + } + } + + Scaffold( + containerColor = DarkBg, + topBar = { + Surface( + color = SurfaceBg.copy(alpha = 0.95f), + modifier = Modifier.statusBarsPadding() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // App Icon + Box( + modifier = Modifier + .size(44.dp) + .background(AccentBlue.copy(alpha = 0.2f), CircleShape), + contentAlignment = Alignment.Center + ) { + Icon( + Icons.Filled.CameraAlt, + contentDescription = null, + tint = AccentBlue, + modifier = Modifier.size(24.dp) + ) + } + + Spacer(modifier = Modifier.width(12.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + "AirLink", + fontSize = 22.sp, + fontWeight = FontWeight.Bold, + color = TextPrimary + ) + Text( + "Wireless Camera", + fontSize = 13.sp, + color = TextSecondary + ) + } + + // Manual connect button + IconButton( + onClick = { showManualDialog = true }, + modifier = Modifier + .size(44.dp) + .background(CardBg, CircleShape) + ) { + Icon( + Icons.Outlined.Add, + "Manual IP", + tint = AccentCyan, + modifier = Modifier.size(22.dp) + ) + } + } + } + } + ) { padding -> + Box( + modifier = Modifier + .padding(padding) + .fillMaxSize() + ) { + if (servers.isEmpty()) { + // Empty State - Scanning + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + // Animated scanning indicator + Box( + modifier = Modifier + .size(120.dp) + .background(CardBg, CircleShape) + .border(2.dp, AccentBlue.copy(alpha = 0.3f), CircleShape), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + color = AccentBlue, + strokeWidth = 3.dp, + modifier = Modifier.size(80.dp) + ) + } + + Spacer(modifier = Modifier.height(32.dp)) + + Text( + "Scanning Network", + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + color = TextPrimary + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + "Looking for available devices...", + fontSize = 15.sp, + color = TextSecondary, + textAlign = androidx.compose.ui.text.style.TextAlign.Center + ) + + Spacer(modifier = Modifier.height(40.dp)) + + // Manual connect card + PremiumCard( + onClick = { showManualDialog = true } + ) { + Row( + modifier = Modifier.padding(20.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(48.dp) + .background(AccentCyan.copy(alpha = 0.15f), CircleShape), + contentAlignment = Alignment.Center + ) { + Icon( + Icons.Outlined.Edit, + null, + tint = AccentCyan, + modifier = Modifier.size(24.dp) + ) + } + + Spacer(modifier = Modifier.width(16.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + "Manual Connection", + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + color = TextPrimary + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + "Enter IP address", + fontSize = 13.sp, + color = TextSecondary + ) + } + + Icon( + Icons.Filled.ChevronRight, + null, + tint = TextSecondary + ) + } + } + } + } else { + // Servers List + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp, vertical = 16.dp) + ) { + // Section Header + Text( + "AVAILABLE DEVICES", + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + color = TextSecondary, + letterSpacing = 1.sp + ) + + Spacer(modifier = Modifier.height(12.dp)) + + servers.forEach { server -> + ServerCard(server, onClick = { onServerSelected(server) }) + Spacer(modifier = Modifier.height(12.dp)) + } + + // Manual connect option + PremiumCard( + onClick = { showManualDialog = true }, + backgroundColor = CardBgLight + ) { + Row( + modifier = Modifier.padding(20.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(48.dp) + .background(AccentCyan.copy(alpha = 0.15f), CircleShape), + contentAlignment = Alignment.Center + ) { + Icon( + Icons.Outlined.Add, + null, + tint = AccentCyan, + modifier = Modifier.size(24.dp) + ) + } + + Spacer(modifier = Modifier.width(16.dp)) + + Text( + "Add Manual Connection", + fontWeight = FontWeight.Medium, + fontSize = 16.sp, + color = AccentCyan, + modifier = Modifier.weight(1f) + ) + + Icon( + Icons.Filled.ChevronRight, + null, + tint = AccentCyan.copy(alpha = 0.5f) + ) + } + } + + Spacer(modifier = Modifier.height(80.dp)) + } + } + } + } + + if (showManualDialog) { + ManualIPDialog( + onDismiss = { showManualDialog = false }, + onConnect = { ip, port -> + try { + val addr = InetAddress.getByName(ip) + val server = ServerInfo("Manual: $ip", addr, port) + onServerSelected(server) + showManualDialog = false + } catch (e: Exception) { + // Show error + } + } + ) + } +} + +@Composable +fun PremiumCard( + onClick: () -> Unit, + backgroundColor: Color = CardBg, + content: @Composable () -> Unit +) { + Card( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick), + colors = CardDefaults.cardColors(containerColor = backgroundColor), + shape = RoundedCornerShape(20.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) + ) { + content() + } +} + +@Composable +fun ServerCard(server: ServerInfo, onClick: () -> Unit) { + PremiumCard(onClick = onClick) { + Row( + modifier = Modifier.padding(20.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Device Icon with pulse animation + Box( + modifier = Modifier + .size(56.dp) + .background(AccentBlue.copy(alpha = 0.15f), CircleShape), + contentAlignment = Alignment.Center + ) { + Icon( + Icons.Filled.Laptop, + null, + tint = AccentBlue, + modifier = Modifier.size(28.dp) + ) + } + + Spacer(modifier = Modifier.width(16.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + server.name, + fontWeight = FontWeight.SemiBold, + color = TextPrimary, + fontSize = 17.sp + ) + Spacer(modifier = Modifier.height(6.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(6.dp) + .background(AccentGreen, CircleShape) + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + server.ip.hostAddress ?: "", + fontSize = 14.sp, + color = TextSecondary + ) + } + } + + Icon( + Icons.Filled.ChevronRight, + null, + tint = TextSecondary + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ManualIPDialog( + onDismiss: () -> Unit, + onConnect: (String, Int) -> Unit +) { + var ipText by remember { mutableStateOf("") } + var portText by remember { mutableStateOf("5000") } + + AlertDialog( + onDismissRequest = onDismiss, + confirmButton = { + Button( + onClick = { + val port = portText.toIntOrNull() ?: 5000 + onConnect(ipText, port) + }, + enabled = ipText.isNotBlank(), + modifier = Modifier + .fillMaxWidth() + .height(48.dp), + colors = ButtonDefaults.buttonColors( + containerColor = AccentBlue, + disabledContainerColor = AccentBlue.copy(alpha = 0.3f) + ), + shape = RoundedCornerShape(12.dp) + ) { + Text("Connect", fontWeight = FontWeight.Bold, color = Color.White) + } + }, + dismissButton = { + OutlinedButton( + onClick = onDismiss, + modifier = Modifier + .fillMaxWidth() + .height(48.dp), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = TextSecondary + ), + border = BorderStroke(1.dp, DividerColor), + shape = RoundedCornerShape(12.dp) + ) { + Text("Cancel", fontWeight = FontWeight.Medium) + } + }, + title = { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(44.dp) + .background(AccentCyan.copy(alpha = 0.15f), CircleShape), + contentAlignment = Alignment.Center + ) { + Icon( + Icons.Outlined.Edit, + null, + tint = AccentCyan, + modifier = Modifier.size(22.dp) + ) + } + + Spacer(modifier = Modifier.width(12.dp)) + + Text( + "Manual Connection", + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + color = TextPrimary + ) + } + }, + text = { + Column( + modifier = Modifier.fillMaxWidth() + ) { + Text( + "Connect to a device over the internet or VPN", + color = TextSecondary, + fontSize = 14.sp + ) + + Spacer(modifier = Modifier.height(24.dp)) + + // IP Address + Text( + "IP ADDRESS", + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + color = TextSecondary, + letterSpacing = 1.sp + ) + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value = ipText, + onValueChange = { ipText = it }, + placeholder = { Text("192.168.1.100", color = TextSecondary.copy(alpha = 0.5f)) }, + singleLine = true, + colors = TextFieldDefaults.colors( + focusedContainerColor = CardBg, + unfocusedContainerColor = CardBg, + focusedTextColor = TextPrimary, + unfocusedTextColor = TextPrimary, + focusedIndicatorColor = AccentBlue, + unfocusedIndicatorColor = Color.Transparent, + cursorColor = AccentBlue + ), + shape = RoundedCornerShape(12.dp), + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Port + Text( + "PORT", + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + color = TextSecondary, + letterSpacing = 1.sp + ) + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value = portText, + onValueChange = { portText = it }, + placeholder = { Text("5000", color = TextSecondary.copy(alpha = 0.5f)) }, + singleLine = true, + colors = TextFieldDefaults.colors( + focusedContainerColor = CardBg, + unfocusedContainerColor = CardBg, + focusedTextColor = TextPrimary, + unfocusedTextColor = TextPrimary, + focusedIndicatorColor = AccentBlue, + unfocusedIndicatorColor = Color.Transparent, + cursorColor = AccentBlue + ), + shape = RoundedCornerShape(12.dp), + modifier = Modifier.fillMaxWidth() + ) + } + }, + shape = RoundedCornerShape(24.dp), + containerColor = OverlayBg, + iconContentColor = AccentCyan, + titleContentColor = TextPrimary, + textContentColor = TextSecondary, + tonalElevation = 0.dp + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DashboardScreen( + streamer: CameraStreamer, + server: ServerInfo, + isStreaming: Boolean, + onStreamingChange: (Boolean) -> Unit, + onBack: () -> Unit +) { + var isFrontCamera by remember { mutableStateOf(false) } + var capabilities by remember { mutableStateOf(null) } + var selectedConfig by remember { mutableStateOf(null) } + var wbMode by remember { mutableStateOf(WhiteBalance.AUTO) } + var isFlashOn by remember { mutableStateOf(false) } + var zoom by remember { mutableStateOf(1f) } + var focusMode by remember { mutableStateOf("Auto") } + var focusDist by remember { mutableStateOf(0f) } + var exposureComp by remember { mutableStateOf(0) } + var errorMessage by remember { mutableStateOf(null) } + + LaunchedEffect(Unit) { + streamer.setErrorCallback { error -> + errorMessage = error + if (isStreaming) { + onStreamingChange(false) + } + } + + streamer.setDisconnectedCallback { + errorMessage = "Server disconnected" + onStreamingChange(false) + onBack() + } + } + + LaunchedEffect(isFrontCamera) { + try { + val caps = streamer.getCameraCapabilities(isFrontCamera) + capabilities = caps + selectedConfig = caps.supportedConfigs.firstOrNull() + + if (isStreaming && selectedConfig != null) { + streamer.start(server.ip, server.port, selectedConfig!!, isFrontCamera) + } + } catch (e: Exception) { + errorMessage = "Camera initialization failed" + } + } + + LaunchedEffect(errorMessage) { + if (errorMessage != null) { + delay(5000) + errorMessage = null + } + } + + Scaffold( + containerColor = DarkBg, + topBar = { + Surface( + color = SurfaceBg.copy(alpha = 0.95f), + modifier = Modifier.statusBarsPadding() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + IconButton( + onClick = onBack, + modifier = Modifier + .size(44.dp) + .background(CardBg, CircleShape) + ) { + Icon( + Icons.Filled.ArrowBack, + null, + tint = TextPrimary, + modifier = Modifier.size(20.dp) + ) + } + + Spacer(modifier = Modifier.width(12.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + server.name, + fontWeight = FontWeight.Bold, + fontSize = 18.sp, + color = TextPrimary + ) + Spacer(modifier = Modifier.height(4.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(8.dp) + .background( + if (isStreaming) AccentGreen else AccentRed, + CircleShape + ) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + if (isStreaming) "STREAMING" else "OFFLINE", + fontSize = 13.sp, + color = if (isStreaming) AccentGreen else TextSecondary, + fontWeight = FontWeight.Medium, + letterSpacing = 0.5.sp + ) + } + } + + if (capabilities?.hasFrontCamera == true) { + IconButton( + onClick = { isFrontCamera = !isFrontCamera }, + modifier = Modifier + .size(44.dp) + .background(CardBg, CircleShape) + ) { + Icon( + Icons.Filled.Cameraswitch, + contentDescription = "Flip Camera", + tint = AccentCyan, + modifier = Modifier.size(20.dp) + ) + } + } + } + } + }, + bottomBar = { + Surface( + color = SurfaceBg.copy(alpha = 0.95f), + modifier = Modifier.navigationBarsPadding() + ) { + Column { + AnimatedVisibility(visible = errorMessage != null) { + Surface( + color = AccentRed.copy(alpha = 0.15f), + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Filled.Error, + null, + tint = AccentRed, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + Text(errorMessage ?: "", color = AccentRed, fontSize = 14.sp) + } + } + } + + Box(modifier = Modifier.padding(20.dp)) { + Button( + onClick = { + if (isStreaming) { + streamer.stop() + onStreamingChange(false) + } else { + selectedConfig?.let { config -> + try { + streamer.start(server.ip, server.port, config, isFrontCamera) + onStreamingChange(true) + errorMessage = null + } catch (e: Exception) { + errorMessage = "Failed to start stream" + } + } + } + }, + modifier = Modifier + .fillMaxWidth() + .height(56.dp), + colors = ButtonDefaults.buttonColors( + containerColor = if (isStreaming) AccentRed else AccentBlue + ), + shape = RoundedCornerShape(16.dp), + enabled = selectedConfig != null + ) { + Icon( + if (isStreaming) Icons.Filled.Stop else Icons.Filled.PlayArrow, + null, + modifier = Modifier.size(22.dp), + tint = Color.White + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + if (isStreaming) "STOP STREAM" else "START STREAM", + fontWeight = FontWeight.Bold, + fontSize = 16.sp, + color = Color.White, + letterSpacing = 0.5.sp + ) + } + } + } + } + } + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + // Resolution + CompactDropdown( + icon = Icons.Outlined.Videocam, + label = "Resolution", + currentValue = selectedConfig?.toDetailedString() ?: "Loading...", + items = capabilities?.supportedConfigs ?: emptyList(), + onItemSelected = { + selectedConfig = it + if (isStreaming) { + streamer.start(server.ip, server.port, it, isFrontCamera) + } + }, + itemLabel = { it.toDetailedString() } + ) + + // White Balance + CompactDropdown( + icon = Icons.Outlined.WbSunny, + label = "White Balance", + currentValue = wbMode.label, + items = WhiteBalance.values().toList(), + onItemSelected = { + wbMode = it + streamer.setWhiteBalance(it) + }, + itemLabel = { it.label } + ) + + // Focus + if (capabilities?.focusRange != null) { + val focusModes = listOf("Auto", "Manual") + CompactDropdown( + icon = Icons.Outlined.CenterFocusStrong, + label = "Focus", + currentValue = if (focusMode == "Manual") "Manual (${String.format("%.1f", focusDist)})" else focusMode, + items = focusModes, + onItemSelected = { + focusMode = it + streamer.setFocusMode(it == "Auto") + }, + itemLabel = { it }, + expandedContent = if (focusMode == "Manual") { + { + Slider( + value = focusDist, + onValueChange = { + focusDist = it + streamer.setFocusDistance(it) + }, + valueRange = 0f..(capabilities?.focusRange?.upper ?: 1f), + colors = SliderDefaults.colors( + thumbColor = AccentCyan, + activeTrackColor = AccentCyan, + inactiveTrackColor = DividerColor + ), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) + } + } else null + ) + } + + // Exposure + if (capabilities?.exposureRange != null) { + CompactSlider( + icon = Icons.Outlined.Brightness6, + label = "Exposure", + value = exposureComp.toFloat(), + displayValue = if (exposureComp == 0) "Auto" else exposureComp.toString(), + onValueChange = { + exposureComp = it.toInt() + streamer.setExposure(exposureComp) + }, + valueRange = (capabilities?.exposureRange?.lower ?: -12).toFloat()..(capabilities?.exposureRange?.upper ?: 12).toFloat(), + accentColor = AccentOrange + ) + } + + // Zoom + if ((capabilities?.maxZoom ?: 1f) > 1f) { + CompactSlider( + icon = Icons.Outlined.ZoomIn, + label = "Zoom", + value = zoom, + displayValue = "${String.format("%.1f", zoom)}×", + onValueChange = { + zoom = it + streamer.setZoom(it) + }, + valueRange = 1f..(capabilities?.maxZoom ?: 1f), + accentColor = AccentGreen + ) + } + + // Flash + if (capabilities?.hasFlash == true && !isFrontCamera) { + CompactToggle( + icon = Icons.Filled.FlashOn, + label = "Flash", + checked = isFlashOn, + onCheckedChange = { + isFlashOn = it + streamer.setTorch(it) + }, + accentColor = AccentOrange + ) + } + + Spacer(modifier = Modifier.height(40.dp)) + } + } +} + +@Composable +fun CompactDropdown( + icon: ImageVector, + label: String, + currentValue: String, + items: List, + onItemSelected: (T) -> Unit, + itemLabel: (T) -> String, + expandedContent: (@Composable () -> Unit)? = null +) { + var expanded by remember { mutableStateOf(false) } + + Card( + colors = CardDefaults.cardColors(containerColor = CardBg), + shape = RoundedCornerShape(16.dp) + ) { + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { expanded = true } + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + icon, + null, + tint = AccentBlue, + modifier = Modifier.size(20.dp) + ) + + Spacer(modifier = Modifier.width(12.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + label, + fontSize = 13.sp, + color = TextSecondary + ) + Text( + currentValue, + color = TextPrimary, + fontSize = 15.sp, + fontWeight = FontWeight.Medium + ) + } + + Icon( + Icons.Filled.ArrowDropDown, + null, + tint = TextSecondary, + modifier = Modifier + .size(20.dp) + .rotate(if (expanded) 180f else 0f) + ) + } + + expandedContent?.invoke() + } + + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + modifier = Modifier + .background(CardBgLight) + .widthIn(min = 200.dp) + ) { + items.forEach { item -> + DropdownMenuItem( + text = { + Text( + itemLabel(item), + color = TextPrimary, + fontSize = 15.sp + ) + }, + onClick = { + onItemSelected(item) + expanded = false + } + ) + } + } + } +} + +@Composable +fun CompactSlider( + icon: ImageVector, + label: String, + value: Float, + displayValue: String, + onValueChange: (Float) -> Unit, + valueRange: ClosedFloatingPointRange, + accentColor: Color = AccentBlue +) { + Card( + colors = CardDefaults.cardColors(containerColor = CardBg), + shape = RoundedCornerShape(16.dp) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + icon, + null, + tint = accentColor, + modifier = Modifier.size(20.dp) + ) + + Spacer(modifier = Modifier.width(12.dp)) + + Text( + label, + fontSize = 13.sp, + color = TextSecondary, + modifier = Modifier.weight(1f) + ) + + Text( + displayValue, + color = TextPrimary, + fontSize = 15.sp, + fontWeight = FontWeight.Medium + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Slider( + value = value, + onValueChange = onValueChange, + valueRange = valueRange, + colors = SliderDefaults.colors( + thumbColor = accentColor, + activeTrackColor = accentColor, + inactiveTrackColor = DividerColor + ) + ) + } + } +} + +@Composable +fun CompactToggle( + icon: ImageVector, + label: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + accentColor: Color = AccentBlue +) { + Card( + colors = CardDefaults.cardColors(containerColor = CardBg), + shape = RoundedCornerShape(16.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + icon, + null, + tint = if (checked) accentColor else TextSecondary, + modifier = Modifier.size(20.dp) + ) + + Spacer(modifier = Modifier.width(12.dp)) + + Text( + label, + fontSize = 15.sp, + color = TextPrimary, + modifier = Modifier.weight(1f) + ) + + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + colors = SwitchDefaults.colors( + checkedThumbColor = Color.White, + checkedTrackColor = accentColor, + uncheckedThumbColor = Color.White, + uncheckedTrackColor = DividerColor + ) + ) + } + } +} + +@Composable +fun ControlHeader( + icon: ImageVector, + title: String, + subtitle: String? = null, + tint: Color = AccentBlue +) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + icon, + null, + tint = tint, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(10.dp)) + Column { + Text( + title, + color = TextPrimary, + fontWeight = FontWeight.SemiBold, + fontSize = 15.sp + ) + if (subtitle != null) { + Text( + subtitle, + color = TextSecondary, + fontSize = 13.sp + ) + } + } + } +} + +@Composable +fun PremiumDropdown( + label: String, + currentValue: String, + icon: ImageVector, + items: List, + onItemSelected: (T) -> Unit, + itemLabel: (T) -> String +) { + var expanded by remember { mutableStateOf(false) } + + Card( + modifier = Modifier + .fillMaxWidth() + .clickable { expanded = true }, + colors = CardDefaults.cardColors(containerColor = CardBg), + shape = RoundedCornerShape(16.dp) + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(44.dp) + .background(AccentBlue.copy(alpha = 0.15f), CircleShape), + contentAlignment = Alignment.Center + ) { + Icon( + icon, + null, + tint = AccentBlue, + modifier = Modifier.size(22.dp) + ) + } + + Spacer(modifier = Modifier.width(14.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + label, + fontSize = 13.sp, + color = TextSecondary + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + currentValue, + color = TextPrimary, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold + ) + } + + Icon( + Icons.Filled.ArrowDropDown, + null, + tint = TextSecondary, + modifier = Modifier.rotate(if (expanded) 180f else 0f) + ) + } + + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + modifier = Modifier + .background(CardBg) + .widthIn(min = 200.dp) + ) { + items.forEach { item -> + DropdownMenuItem( + text = { + Text( + itemLabel(item), + color = TextPrimary, + fontSize = 15.sp + ) + }, + onClick = { + onItemSelected(item) + expanded = false + } + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/srtk/airlink/NetworkDiscovery.kt b/app/src/main/java/com/srtk/airlink/NetworkDiscovery.kt new file mode 100644 index 0000000..38953e0 --- /dev/null +++ b/app/src/main/java/com/srtk/airlink/NetworkDiscovery.kt @@ -0,0 +1,125 @@ +package com.srtk.airlink + +import android.content.Context +import android.net.nsd.NsdManager +import android.net.nsd.NsdServiceInfo +import android.util.Log +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import java.net.InetAddress + +data class ServerInfo( + val name: String, + val ip: InetAddress, + val port: Int +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is ServerInfo) return false + return ip == other.ip && port == other.port + } + + override fun hashCode(): Int { + return 31 * ip.hashCode() + port + } +} + +class DiscoveryManager(context: Context) { + private val TAG = "DiscoveryManager" + private val nsdManager = context.getSystemService( + Context.NSD_SERVICE + ) as NsdManager + private val SERVICE_TYPE = "_procam._udp." + + fun discover(): Flow = callbackFlow { + val discoveryListener = object : NsdManager.DiscoveryListener { + override fun onDiscoveryStarted(regType: String) { + Log.d(TAG, "Discovery started") + } + + override fun onServiceFound(service: NsdServiceInfo) { + if (service.serviceType.contains("_procam")) { + try { + nsdManager.resolveService( + service, + object : NsdManager.ResolveListener { + override fun onResolveFailed( + serviceInfo: NsdServiceInfo, + errorCode: Int + ) { + Log.w(TAG, "Resolve failed: $errorCode") + } + + override fun onServiceResolved( + serviceInfo: NsdServiceInfo + ) { + try { + val server = ServerInfo( + name = serviceInfo.serviceName, + ip = serviceInfo.host, + port = serviceInfo.port + ) + trySend(server) + } catch (e: Exception) { + Log.e( + TAG, + "Error creating ServerInfo: ${e.message}" + ) + } + } + } + ) + } catch (e: Exception) { + Log.e(TAG, "Resolve error: ${e.message}") + } + } + } + + override fun onServiceLost(service: NsdServiceInfo) { + Log.d(TAG, "Service lost: ${service.serviceName}") + } + + override fun onDiscoveryStopped(serviceType: String) { + Log.d(TAG, "Discovery stopped") + } + + override fun onStartDiscoveryFailed( + serviceType: String, + errorCode: Int + ) { + Log.e(TAG, "Start discovery failed: $errorCode") + try { + nsdManager.stopServiceDiscovery(this) + } catch (e: Exception) { + Log.e(TAG, "Stop discovery error: ${e.message}") + } + } + + override fun onStopDiscoveryFailed( + serviceType: String, + errorCode: Int + ) { + Log.e(TAG, "Stop discovery failed: $errorCode") + } + } + + try { + nsdManager.discoverServices( + SERVICE_TYPE, + NsdManager.PROTOCOL_DNS_SD, + discoveryListener + ) + } catch (e: Exception) { + Log.e(TAG, "Discovery start error: ${e.message}") + } + + awaitClose { + try { + nsdManager.stopServiceDiscovery(discoveryListener) + } catch (e: Exception) { + Log.e(TAG, "Discovery cleanup error: ${e.message}") + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/srtk/airlink/ui/theme/Color.kt b/app/src/main/java/com/srtk/airlink/ui/theme/Color.kt new file mode 100644 index 0000000..e0a665c --- /dev/null +++ b/app/src/main/java/com/srtk/airlink/ui/theme/Color.kt @@ -0,0 +1,11 @@ +package com.srtk.airlink.ui.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) \ No newline at end of file diff --git a/app/src/main/java/com/srtk/airlink/ui/theme/Theme.kt b/app/src/main/java/com/srtk/airlink/ui/theme/Theme.kt new file mode 100644 index 0000000..54c51dd --- /dev/null +++ b/app/src/main/java/com/srtk/airlink/ui/theme/Theme.kt @@ -0,0 +1,57 @@ +package com.srtk.airlink.ui.theme + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +private val DarkColorScheme = darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80 +) + +private val LightColorScheme = lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40 + + /* Other default colors to override + background = Color(0xFFFFFBFE), + surface = Color(0xFFFFFBFE), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = Color(0xFF1C1B1F), + onSurface = Color(0xFF1C1B1F), + */ +) + +@Composable +fun WebcamTheme( + 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 -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/srtk/airlink/ui/theme/Type.kt b/app/src/main/java/com/srtk/airlink/ui/theme/Type.kt new file mode 100644 index 0000000..f1ce894 --- /dev/null +++ b/app/src/main/java/com/srtk/airlink/ui/theme/Type.kt @@ -0,0 +1,34 @@ +package com.srtk.airlink.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) \ No newline at end of file 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 100644 index 0000000..ca3826a --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 100644 index 0000000..2b068d1 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file 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 100644 index 0000000..c4a603d --- /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 100644 index 0000000..c4a603d --- /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 100644 index 0000000..01a6858 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_foreground.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..c087f1f Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.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 100644 index 0000000..64a5d8b 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 100644 index 0000000..bbc8590 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_foreground.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..374a30c Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.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 100644 index 0000000..641294b 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 100644 index 0000000..3f54a67 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_foreground.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..54d9882 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.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 100644 index 0000000..f07db95 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 100644 index 0000000..ec2dd51 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_foreground.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..4054ee8 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.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 100644 index 0000000..2fa136a 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 100644 index 0000000..24d22c9 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_foreground.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..d4b2f1f Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.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 100644 index 0000000..e0fad86 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/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 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/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..315bf09 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + webcam + \ 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 100644 index 0000000..79dfde6 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +