wireless camera app for Android

Stream a phone's camera to a tablet over WiFi/hotspot.
WebSocket transport with native YUV-to-JPEG encoding,
UDP auto-discovery, remote control (zoom, flash, camera switch,
rotate, mirror) from the viewer.
This commit is contained in:
2026-03-06 18:14:27 +00:00
commit 1660696d7f
32 changed files with 1962 additions and 0 deletions

View File

@@ -0,0 +1,44 @@
plugins {
id("com.android.application")
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "com.kschappell.external_cam"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.kschappell.external_cam"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
}
}
}
flutter {
source = "../.."
}

View File

@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@@ -0,0 +1,41 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<application
android:label="External Cam"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>

View File

@@ -0,0 +1,100 @@
package com.kschappell.external_cam
import android.content.Context
import android.graphics.ImageFormat
import android.graphics.Rect
import android.graphics.YuvImage
import android.net.wifi.WifiManager
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import java.io.ByteArrayOutputStream
class MainActivity : FlutterActivity() {
private var multicastLock: WifiManager.MulticastLock? = null
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
acquireMulticastLock()
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "com.kschappell.external_cam/jpeg")
.setMethodCallHandler { call, result ->
if (call.method == "encodeJpeg") {
result.success(
encodeYuvToJpeg(
width = call.argument("width")!!,
height = call.argument("height")!!,
yBuffer = call.argument("y")!!,
uBuffer = call.argument("u")!!,
vBuffer = call.argument("v")!!,
yRowStride = call.argument("yRowStride")!!,
uvRowStride = call.argument("uvRowStride")!!,
uvPixelStride = call.argument("uvPixelStride")!!,
quality = call.argument("quality") ?: 70,
)
)
} else {
result.notImplemented()
}
}
}
private fun acquireMulticastLock() {
val wifi = applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
multicastLock = wifi.createMulticastLock("external_cam").apply {
setReferenceCounted(true)
acquire()
}
}
override fun onDestroy() {
multicastLock?.release()
super.onDestroy()
}
private fun encodeYuvToJpeg(
width: Int,
height: Int,
yBuffer: ByteArray,
uBuffer: ByteArray,
vBuffer: ByteArray,
yRowStride: Int,
uvRowStride: Int,
uvPixelStride: Int,
quality: Int,
): ByteArray {
val nv21 = ByteArray(width * height * 3 / 2)
var pos = 0
if (yRowStride == width) {
System.arraycopy(yBuffer, 0, nv21, 0, width * height)
pos = width * height
} else {
for (row in 0 until height) {
System.arraycopy(yBuffer, row * yRowStride, nv21, pos, width)
pos += width
}
}
val uvHeight = height / 2
val uvWidth = width / 2
val uvSize = uvWidth * uvHeight * 2
if (uvPixelStride == 2 && uvRowStride == width && vBuffer.size >= uvSize) {
System.arraycopy(vBuffer, 0, nv21, pos, uvSize)
} else {
for (row in 0 until uvHeight) {
val offset = row * uvRowStride
for (col in 0 until uvWidth) {
val uvOffset = offset + col * uvPixelStride
nv21[pos++] = vBuffer[uvOffset]
nv21[pos++] = uBuffer[uvOffset]
}
}
}
val yuvImage = YuvImage(nv21, ImageFormat.NV21, width, height, null)
val stream = ByteArrayOutputStream(width * height / 4)
yuvImage.compressToJpeg(Rect(0, 0, width, height), quality, stream)
return stream.toByteArray()
}
}

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>