|
|
|
@ -2,6 +2,7 @@ package com.dzecevic.babymonitor |
|
|
|
|
|
|
|
import android.Manifest |
|
|
|
import android.content.Context |
|
|
|
import android.content.Intent |
|
|
|
import android.content.pm.PackageManager |
|
|
|
import android.net.ConnectivityManager |
|
|
|
import android.net.NetworkCapabilities |
|
|
|
@ -19,88 +20,67 @@ import androidx.compose.foundation.layout.* |
|
|
|
import androidx.compose.material3.Button |
|
|
|
import androidx.compose.material3.Text |
|
|
|
import androidx.compose.runtime.* |
|
|
|
import androidx.compose.ui.Alignment |
|
|
|
import androidx.compose.ui.Modifier |
|
|
|
import androidx.compose.ui.graphics.Color |
|
|
|
import androidx.compose.ui.platform.LocalContext |
|
|
|
import androidx.compose.ui.unit.dp |
|
|
|
import androidx.compose.ui.viewinterop.AndroidView |
|
|
|
import androidx.core.content.ContextCompat |
|
|
|
import androidx.activity.compose.rememberLauncherForActivityResult |
|
|
|
import java.net.Inet4Address |
|
|
|
import java.net.NetworkInterface |
|
|
|
import java.util.concurrent.ExecutorService |
|
|
|
import java.util.concurrent.Executors |
|
|
|
import java.util.concurrent.atomic.AtomicInteger |
|
|
|
import java.util.concurrent.atomic.AtomicReference |
|
|
|
import androidx.compose.runtime.mutableStateOf |
|
|
|
import androidx.compose.runtime.getValue |
|
|
|
import androidx.compose.runtime.setValue |
|
|
|
import androidx.compose.foundation.layout.statusBarsPadding |
|
|
|
import android.content.Intent |
|
|
|
import com.dzecevic.babymonitor.FrameStore |
|
|
|
import androidx.compose.ui.Alignment |
|
|
|
|
|
|
|
|
|
|
|
class MainActivity : ComponentActivity() { |
|
|
|
|
|
|
|
private val latestJpeg = AtomicReference<ByteArray?>(null) |
|
|
|
private val serverPort = 8080 |
|
|
|
|
|
|
|
private var cameraProvider: ProcessCameraProvider? = null |
|
|
|
private lateinit var cameraExecutor: ExecutorService |
|
|
|
private var cameraProvider: ProcessCameraProvider? = null |
|
|
|
private var lensFacing = CameraSelector.LENS_FACING_BACK |
|
|
|
|
|
|
|
private val frameCount = AtomicInteger(0) |
|
|
|
|
|
|
|
private var rebindToken by mutableStateOf(0) |
|
|
|
|
|
|
|
private val requestPermission = registerForActivityResult( |
|
|
|
ActivityResultContracts.RequestPermission() |
|
|
|
) { granted -> |
|
|
|
if (!granted) Toast.makeText(this, "Camera permission required", Toast.LENGTH_LONG).show() |
|
|
|
} |
|
|
|
private val cameraPermissionLauncher = |
|
|
|
registerForActivityResult(ActivityResultContracts.RequestPermission()) { } |
|
|
|
|
|
|
|
override fun onCreate(savedInstanceState: Bundle?) { |
|
|
|
super.onCreate(savedInstanceState) |
|
|
|
|
|
|
|
cameraExecutor = Executors.newSingleThreadExecutor() |
|
|
|
|
|
|
|
// request permission if needed |
|
|
|
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) |
|
|
|
!= PackageManager.PERMISSION_GRANTED |
|
|
|
) { |
|
|
|
requestPermission.launch(Manifest.permission.CAMERA) |
|
|
|
cameraPermissionLauncher.launch(Manifest.permission.CAMERA) |
|
|
|
} |
|
|
|
|
|
|
|
setContent { |
|
|
|
BabyMonitorScreen( |
|
|
|
onStartServer = { ip -> |
|
|
|
val intent = Intent(this, StreamService::class.java) |
|
|
|
ContextCompat.startForegroundService(this, intent) |
|
|
|
|
|
|
|
Toast.makeText(this, "Open: http://$ip:$serverPort/", Toast.LENGTH_LONG).show() |
|
|
|
FrameStore.isStreaming = true |
|
|
|
ContextCompat.startForegroundService( |
|
|
|
this, |
|
|
|
Intent(this, StreamService::class.java) |
|
|
|
) |
|
|
|
Toast.makeText(this, "Open http://$ip:8080/", Toast.LENGTH_LONG).show() |
|
|
|
}, |
|
|
|
|
|
|
|
onStopServer = { |
|
|
|
FrameStore.isStreaming = false |
|
|
|
stopService(Intent(this, StreamService::class.java)) |
|
|
|
}, |
|
|
|
|
|
|
|
isServerRunning = { false }, |
|
|
|
|
|
|
|
onSwitchCamera = { |
|
|
|
lensFacing = |
|
|
|
if (lensFacing == CameraSelector.LENS_FACING_BACK) CameraSelector.LENS_FACING_FRONT |
|
|
|
else CameraSelector.LENS_FACING_BACK |
|
|
|
rebindToken++ |
|
|
|
}, |
|
|
|
|
|
|
|
rebindToken = rebindToken, |
|
|
|
if (lensFacing == CameraSelector.LENS_FACING_BACK) |
|
|
|
CameraSelector.LENS_FACING_FRONT |
|
|
|
else |
|
|
|
CameraSelector.LENS_FACING_BACK |
|
|
|
|
|
|
|
bindCamera = { previewView -> |
|
|
|
bindCameraUseCases(previewView) |
|
|
|
rebindToken++ |
|
|
|
}, |
|
|
|
|
|
|
|
bindCamera = { bindCameraUseCases(it) }, |
|
|
|
getIp = { getLocalWifiIp() }, |
|
|
|
|
|
|
|
getFrames = { frameCount.get() } |
|
|
|
rebindToken = rebindToken |
|
|
|
) |
|
|
|
} |
|
|
|
} |
|
|
|
@ -111,15 +91,12 @@ class MainActivity : ComponentActivity() { |
|
|
|
} |
|
|
|
|
|
|
|
private fun bindCameraUseCases(previewView: PreviewView) { |
|
|
|
if (FrameStore.isStreaming) { |
|
|
|
return |
|
|
|
} |
|
|
|
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) |
|
|
|
!= PackageManager.PERMISSION_GRANTED |
|
|
|
) return |
|
|
|
|
|
|
|
if (FrameStore.isStreaming) return |
|
|
|
|
|
|
|
val providerFuture = ProcessCameraProvider.getInstance(this) |
|
|
|
providerFuture.addListener({ |
|
|
|
|
|
|
|
val provider = providerFuture.get() |
|
|
|
cameraProvider = provider |
|
|
|
provider.unbindAll() |
|
|
|
@ -130,22 +107,20 @@ class MainActivity : ComponentActivity() { |
|
|
|
|
|
|
|
val analysis = ImageAnalysis.Builder() |
|
|
|
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) |
|
|
|
.setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_YUV_420_888) |
|
|
|
.build() |
|
|
|
|
|
|
|
analysis.setAnalyzer(cameraExecutor) { image -> |
|
|
|
try { |
|
|
|
val rotation = image.imageInfo.rotationDegrees |
|
|
|
val isFront = (lensFacing == CameraSelector.LENS_FACING_FRONT) |
|
|
|
|
|
|
|
val jpg = YuvToJpegConverter.toJpeg( |
|
|
|
image = image, |
|
|
|
quality = 70, |
|
|
|
rotationDegrees = rotation, |
|
|
|
mirrorHorizontally = isFront |
|
|
|
image, |
|
|
|
70, |
|
|
|
image.imageInfo.rotationDegrees, |
|
|
|
lensFacing == CameraSelector.LENS_FACING_FRONT |
|
|
|
) |
|
|
|
|
|
|
|
FrameStore.latestJpeg.set(jpg) |
|
|
|
FrameStore.frameCount.incrementAndGet() |
|
|
|
|
|
|
|
} catch (_: Exception) { |
|
|
|
} finally { |
|
|
|
image.close() |
|
|
|
@ -156,33 +131,28 @@ class MainActivity : ComponentActivity() { |
|
|
|
.requireLensFacing(lensFacing) |
|
|
|
.build() |
|
|
|
|
|
|
|
try { |
|
|
|
provider.bindToLifecycle(this, selector, preview, analysis) |
|
|
|
} catch (e: Exception) { |
|
|
|
Toast.makeText(this, "Camera bind failed: ${e.message}", Toast.LENGTH_LONG).show() |
|
|
|
} |
|
|
|
provider.bindToLifecycle(this, selector, preview, analysis) |
|
|
|
|
|
|
|
}, ContextCompat.getMainExecutor(this)) |
|
|
|
} |
|
|
|
|
|
|
|
private fun getLocalWifiIp(): String? { |
|
|
|
|
|
|
|
if (!isOnWifi()) return null |
|
|
|
|
|
|
|
// Prefer wlan interface addresses |
|
|
|
NetworkInterface.getNetworkInterfaces().toList().forEach { nif -> |
|
|
|
nif.inetAddresses.toList().forEach { addr -> |
|
|
|
if (!addr.isLoopbackAddress && addr is Inet4Address) { |
|
|
|
if (nif.name.startsWith("wlan") || nif.name.startsWith("wifi")) { |
|
|
|
return addr.hostAddress |
|
|
|
} |
|
|
|
if (nif.name.startsWith("wlan")) return addr.hostAddress |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
// Fallback to WifiManager |
|
|
|
val wm = applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager |
|
|
|
val ipInt = wm.connectionInfo.ipAddress |
|
|
|
if (ipInt == 0) return null |
|
|
|
return "${ipInt and 0xFF}.${(ipInt shr 8) and 0xFF}.${(ipInt shr 16) and 0xFF}.${(ipInt shr 24) and 0xFF}" |
|
|
|
val ip = wm.connectionInfo.ipAddress |
|
|
|
if (ip == 0) return null |
|
|
|
|
|
|
|
return "${ip and 0xFF}.${(ip shr 8) and 0xFF}.${(ip shr 16) and 0xFF}.${(ip shr 24) and 0xFF}" |
|
|
|
} |
|
|
|
|
|
|
|
private fun isOnWifi(): Boolean { |
|
|
|
@ -195,21 +165,20 @@ class MainActivity : ComponentActivity() { |
|
|
|
|
|
|
|
@Composable |
|
|
|
private fun BabyMonitorScreen( |
|
|
|
onStartServer: (ip: String) -> Unit, |
|
|
|
onStartServer: (String) -> Unit, |
|
|
|
onStopServer: () -> Unit, |
|
|
|
isServerRunning: () -> Boolean, |
|
|
|
onSwitchCamera: () -> Unit, |
|
|
|
bindCamera: (PreviewView) -> Unit, |
|
|
|
getIp: () -> String?, |
|
|
|
getFrames: () -> Int, |
|
|
|
rebindToken: Int |
|
|
|
) { |
|
|
|
|
|
|
|
val context = LocalContext.current |
|
|
|
var ip by remember { mutableStateOf(getIp()) } |
|
|
|
var running by remember { mutableStateOf(isServerRunning()) } |
|
|
|
var running by remember { mutableStateOf(false) } |
|
|
|
|
|
|
|
// UI refresh of IP when screen recomposes |
|
|
|
LaunchedEffect(Unit) { ip = getIp() } |
|
|
|
val audioPermissionLauncher = |
|
|
|
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { } |
|
|
|
|
|
|
|
Box(Modifier.fillMaxSize()) { |
|
|
|
|
|
|
|
@ -219,87 +188,76 @@ private fun BabyMonitorScreen( |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
if (FrameStore.isStreaming) { |
|
|
|
|
|
|
|
Column( |
|
|
|
modifier = Modifier.fillMaxSize(), |
|
|
|
verticalArrangement = Arrangement.Center, |
|
|
|
horizontalAlignment = Alignment.CenterHorizontally |
|
|
|
) { |
|
|
|
Text("Streaming active", color = Color.White) |
|
|
|
Text("Frames: ${getFrames()}", color = Color.White) |
|
|
|
} |
|
|
|
|
|
|
|
} else { |
|
|
|
|
|
|
|
if (!FrameStore.isStreaming) { |
|
|
|
AndroidView( |
|
|
|
factory = { previewView }, |
|
|
|
modifier = Modifier.fillMaxSize() |
|
|
|
) |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
LaunchedEffect(rebindToken) { |
|
|
|
if (!FrameStore.isStreaming) { |
|
|
|
bindCamera(previewView) |
|
|
|
} |
|
|
|
if (!FrameStore.isStreaming) bindCamera(previewView) |
|
|
|
} |
|
|
|
|
|
|
|
Column( |
|
|
|
Modifier |
|
|
|
.statusBarsPadding() |
|
|
|
.padding(top = 8.dp) |
|
|
|
.padding(horizontal = 12.dp) |
|
|
|
.padding(12.dp) |
|
|
|
.background(Color(0x80000000)) |
|
|
|
.padding(10.dp) |
|
|
|
.padding(12.dp) |
|
|
|
) { |
|
|
|
if (ip != null) { |
|
|
|
Text( |
|
|
|
text = "URL: http://$ip:8080/", |
|
|
|
color = Color.White |
|
|
|
) |
|
|
|
} else { |
|
|
|
Text( |
|
|
|
text = "Not connected to Wi-Fi", |
|
|
|
color = Color.Red |
|
|
|
) |
|
|
|
} |
|
|
|
|
|
|
|
Text( |
|
|
|
text = ip?.let { "http://$it:8080/" } ?: "No Wi-Fi", |
|
|
|
color = Color.White |
|
|
|
) |
|
|
|
|
|
|
|
Spacer(Modifier.height(8.dp)) |
|
|
|
|
|
|
|
Text( |
|
|
|
text = if (running) "Status: STREAMING" else "Status: STOPPED", |
|
|
|
text = if (running) "STREAMING" else "STOPPED", |
|
|
|
color = if (running) Color.Green else Color.Yellow |
|
|
|
) |
|
|
|
|
|
|
|
Spacer(Modifier.height(10.dp)) |
|
|
|
Spacer(Modifier.height(12.dp)) |
|
|
|
|
|
|
|
Row { |
|
|
|
|
|
|
|
Button(onClick = { |
|
|
|
onSwitchCamera() |
|
|
|
ip = getIp() |
|
|
|
}) { Text("Switch camera") } |
|
|
|
}) { Text("Switch") } |
|
|
|
|
|
|
|
Spacer(Modifier.width(8.dp)) |
|
|
|
|
|
|
|
Button(onClick = { |
|
|
|
|
|
|
|
ip = getIp() |
|
|
|
val currentIp = ip |
|
|
|
if (currentIp == null) { |
|
|
|
Toast.makeText(context, "Connect to Wi-Fi first", Toast.LENGTH_LONG).show() |
|
|
|
val currentIp = ip ?: run { |
|
|
|
Toast.makeText(context, "Connect Wi-Fi", Toast.LENGTH_LONG).show() |
|
|
|
return@Button |
|
|
|
} |
|
|
|
|
|
|
|
if (!running) { |
|
|
|
|
|
|
|
if (ContextCompat.checkSelfPermission( |
|
|
|
context, |
|
|
|
Manifest.permission.RECORD_AUDIO |
|
|
|
) != PackageManager.PERMISSION_GRANTED |
|
|
|
) { |
|
|
|
audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) |
|
|
|
} |
|
|
|
|
|
|
|
onStartServer(currentIp) |
|
|
|
running = true |
|
|
|
|
|
|
|
} else { |
|
|
|
onStopServer() |
|
|
|
running = false |
|
|
|
} |
|
|
|
|
|
|
|
}) { |
|
|
|
Text(if (!running) "Start streaming" else "Stop streaming") |
|
|
|
Text(if (!running) "Start" else "Stop") |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
|