From 0732e4daae33cee07052c9475b74b2147d1c05c0 Mon Sep 17 00:00:00 2001 From: kalynstricklin Date: Fri, 26 Jun 2026 13:20:22 -0500 Subject: [PATCH] added camera control, zoom + flip camera --- .../res/drawable/ic_zoom_in.xml | 9 ++ .../res/drawable/ic_zoom_out.xml | 9 ++ .../res/layout/fragment_dashboard.xml | 55 +++++++-- sensorhub-android-app/res/values/strings.xml | 2 + .../sensorhub/android/DashboardFragment.java | 83 +++++++++++++ .../sensor/android/AndroidCameraControl.java | 113 ++++++++++++++++++ .../sensor/android/AndroidSensorsDriver.java | 32 ++++- .../android/video/AndroidCameraOutput.java | 79 ++++++++++++ 8 files changed, 371 insertions(+), 11 deletions(-) create mode 100644 sensorhub-android-app/res/drawable/ic_zoom_in.xml create mode 100644 sensorhub-android-app/res/drawable/ic_zoom_out.xml create mode 100644 sensorhub-driver-android/src/main/java/org/sensorhub/impl/sensor/android/AndroidCameraControl.java diff --git a/sensorhub-android-app/res/drawable/ic_zoom_in.xml b/sensorhub-android-app/res/drawable/ic_zoom_in.xml new file mode 100644 index 0000000..0f499d6 --- /dev/null +++ b/sensorhub-android-app/res/drawable/ic_zoom_in.xml @@ -0,0 +1,9 @@ + + + diff --git a/sensorhub-android-app/res/drawable/ic_zoom_out.xml b/sensorhub-android-app/res/drawable/ic_zoom_out.xml new file mode 100644 index 0000000..f17b746 --- /dev/null +++ b/sensorhub-android-app/res/drawable/ic_zoom_out.xml @@ -0,0 +1,9 @@ + + + diff --git a/sensorhub-android-app/res/layout/fragment_dashboard.xml b/sensorhub-android-app/res/layout/fragment_dashboard.xml index a69bda0..a67b21f 100644 --- a/sensorhub-android-app/res/layout/fragment_dashboard.xml +++ b/sensorhub-android-app/res/layout/fragment_dashboard.xml @@ -73,16 +73,6 @@ - + + + + + + + + + + Enter name or address manually… Scan for new devices… Switch Camera + Zoom In + Zoom Out Stopping SensorHub diff --git a/sensorhub-android-app/src/org/sensorhub/android/DashboardFragment.java b/sensorhub-android-app/src/org/sensorhub/android/DashboardFragment.java index 62f2ee9..67ff07c 100644 --- a/sensorhub-android-app/src/org/sensorhub/android/DashboardFragment.java +++ b/sensorhub-android-app/src/org/sensorhub/android/DashboardFragment.java @@ -76,6 +76,11 @@ public class DashboardFragment extends Fragment implements TextureView.SurfaceTe private TextureView textureView; private MaterialCardView videoStatusCard; private MaterialButton btnToggleVideo; + private MaterialButton btnFlipCamera; + private MaterialButton btnZoomIn; + private MaterialButton btnZoomOut; + private LinearLayout videoControlsOverlay; + private int currentZoomLevel = 0; private MaterialCardView meshtasticCard; private View videoStatusDot; @@ -119,6 +124,16 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat btnToggleVideo.setOnClickListener(v -> toggleVideoPreview()); + videoControlsOverlay = view.findViewById(R.id.video_controls_overlay); + + btnFlipCamera = view.findViewById(R.id.btn_flip_camera); + btnFlipCamera.setOnClickListener(v -> flipCamera()); + + btnZoomIn = view.findViewById(R.id.btn_zoom_in); + btnZoomIn.setOnClickListener(v -> adjustZoom(1)); + btnZoomOut = view.findViewById(R.id.btn_zoom_out); + btnZoomOut.setOnClickListener(v -> adjustZoom(-1)); + meshtasticCard = view.findViewById(R.id.meshtastic_card); view.findViewById(R.id.btn_meshtastic_msg).setOnClickListener(v -> showMeshtasticDialog()); @@ -177,6 +192,8 @@ private void stopHub() { hideVideoPreview(); clearTextureView(); videoStatusCard.setVisibility(View.GONE); + if (videoControlsOverlay != null) videoControlsOverlay.setVisibility(View.GONE); + currentZoomLevel = 0; if (meshtasticCard != null) meshtasticCard.setVisibility(View.GONE); newStatusMessage(getString(R.string.sensorhub_stopped)); requireActivity().getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); @@ -562,6 +579,7 @@ private void updateVideoStatusCard() { boolean hasVideo = service != null && service.hasVideo(); videoStatusCard.setVisibility(hasVideo ? View.VISIBLE : View.GONE); + updateVideoControlsVisibility(); if (hasVideo && videoInfoText.length() > 0) { videoInfoArea.setText(videoInfoText.toString()); @@ -581,15 +599,80 @@ private void toggleVideoPreview() { textureView.setVisibility(View.VISIBLE); btnToggleVideo.setText(R.string.btn_hide); serverStatusContainer.setBackgroundColor(getResources().getColor(R.color.overlay_light, requireActivity().getTheme())); + updateVideoControlsVisibility(); showVideo(); } else { hideVideoPreview(); } } + @SuppressWarnings("deprecation") + private void updateVideoControlsVisibility() { + SensorHubService service = provider.getBoundService(); + boolean hasVideo = service != null && service.hasVideo(); + + if (videoControlsOverlay != null) + videoControlsOverlay.setVisibility(hasVideo && videoPreviewVisible ? View.VISIBLE : View.GONE); + + if (btnFlipCamera != null) { + boolean showFlip = hasVideo && android.hardware.Camera.getNumberOfCameras() > 1; + btnFlipCamera.setVisibility(showFlip ? View.VISIBLE : View.GONE); + } + } + + @SuppressWarnings("deprecation") + private void flipCamera() { + AndroidSensorsDriver sensors = provider.getAndroidSensors(); + if (sensors == null) return; + + try { + int currentId = sensors.getConfiguration().selectedCameraId; + android.hardware.Camera.CameraInfo info = new android.hardware.Camera.CameraInfo(); + android.hardware.Camera.getCameraInfo(currentId, info); + + String targetFacing = (info.facing == android.hardware.Camera.CameraInfo.CAMERA_FACING_BACK) + ? "FRONT" : "BACK"; + + int targetId = -1; + int targetFacingInt = "FRONT".equals(targetFacing) + ? android.hardware.Camera.CameraInfo.CAMERA_FACING_FRONT + : android.hardware.Camera.CameraInfo.CAMERA_FACING_BACK; + + for (int i = 0; i < android.hardware.Camera.getNumberOfCameras(); i++) { + android.hardware.Camera.CameraInfo camInfo = new android.hardware.Camera.CameraInfo(); + android.hardware.Camera.getCameraInfo(i, camInfo); + if (camInfo.facing == targetFacingInt) { + targetId = i; + break; + } + } + + if (targetId >= 0) { + sensors.switchCamera(targetId); + currentZoomLevel = 0; + Toast.makeText(requireContext(), "Switched to " + targetFacing.toLowerCase() + " camera", Toast.LENGTH_SHORT).show(); + } + } catch (Exception e) { + Toast.makeText(requireContext(), "Failed to switch camera", Toast.LENGTH_SHORT).show(); + } + } + + private void adjustZoom(int direction) { + AndroidSensorsDriver sensors = provider.getAndroidSensors(); + if (sensors == null) return; + + try { + currentZoomLevel = Math.max(0, currentZoomLevel + direction); + sensors.setCameraZoom(currentZoomLevel); + } catch (Exception e) { + Toast.makeText(requireContext(), "Zoom not supported", Toast.LENGTH_SHORT).show(); + } + } + private void hideVideoPreview() { videoPreviewVisible = false; textureView.setVisibility(View.GONE); + if (videoControlsOverlay != null) videoControlsOverlay.setVisibility(View.GONE); if (btnToggleVideo != null) btnToggleVideo.setText(R.string.btn_show); serverStatusContainer.setBackgroundColor(0x00000000); } diff --git a/sensorhub-driver-android/src/main/java/org/sensorhub/impl/sensor/android/AndroidCameraControl.java b/sensorhub-driver-android/src/main/java/org/sensorhub/impl/sensor/android/AndroidCameraControl.java new file mode 100644 index 0000000..aea463a --- /dev/null +++ b/sensorhub-driver-android/src/main/java/org/sensorhub/impl/sensor/android/AndroidCameraControl.java @@ -0,0 +1,113 @@ +/***************************** BEGIN LICENSE BLOCK *************************** + +The contents of this file are subject to the Mozilla Public License, v. 2.0. +If a copy of the MPL was not distributed with this file, You can obtain one +at http://mozilla.org/MPL/2.0/. + +Software distributed under the License is distributed on an "AS IS" basis, +WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License +for the specific language governing rights and limitations under the License. + +The Initial Developer is GeoRobotix Innovative Research Inc.. Portions created by the Initial +Developer are Copyright (C) 2026 the Initial Developer. All Rights Reserved. + +******************************* END LICENSE BLOCK ***************************/ + +package org.sensorhub.impl.sensor.android; + + +import android.hardware.Camera; + +import net.opengis.swe.v20.DataBlock; +import net.opengis.swe.v20.DataComponent; + +import org.sensorhub.api.command.CommandException; +import org.sensorhub.api.sensor.SensorException; +import org.sensorhub.impl.sensor.AbstractSensorControl; +import org.vast.swe.SWEHelper; + + +public class AndroidCameraControl extends AbstractSensorControl +{ + private final DataComponent commandDescription; + + // field indices in the command DataBlock + private static final int CAMERA_FACING_IDX = 0; + private static final int ZOOM_LEVEL_IDX = 1; + + + public AndroidCameraControl(AndroidSensorsDriver parentSensor) + { + super("cameraControl", parentSensor); + + SWEHelper fac = new SWEHelper(); + commandDescription = fac.createRecord() + .name("cameraControl") + .addField("cameraFacing", fac.createCategory() + .definition(SWEHelper.getPropertyUri("CameraSelector")) + .label("Camera Facing Direction") + .description("Select front or back camera") + .addAllowedValues("FRONT", "BACK") + .build()) + .addField("zoomLevel", fac.createCount() + .definition(SWEHelper.getPropertyUri("ZoomLevel")) + .label("Zoom Level") + .description("Camera zoom level (0 = no zoom, max depends on device)") + .build()) + .build(); + } + + + @Override + public DataComponent getCommandDescription() + { + return commandDescription; + } + + + @Override + protected boolean execCommand(DataBlock command) throws CommandException + { + try + { + // handle camera facing change + String facing = command.getStringValue(CAMERA_FACING_IDX); + if (facing != null && !facing.isEmpty()) + { + int newCameraId = findCameraId(facing); + parentSensor.switchCamera(newCameraId); + } + + // handle zoom level change + int zoomLevel = command.getIntValue(ZOOM_LEVEL_IDX); + if (zoomLevel >= 0) + { + parentSensor.setCameraZoom(zoomLevel); + } + + return true; + } + catch (SensorException e) + { + throw new CommandException("Failed to execute camera command", e); + } + } + + + private int findCameraId(String facing) throws CommandException + { + int targetFacing = "FRONT".equals(facing) + ? Camera.CameraInfo.CAMERA_FACING_FRONT + : Camera.CameraInfo.CAMERA_FACING_BACK; + + for (int i = 0; i < Camera.getNumberOfCameras(); i++) + { + Camera.CameraInfo info = new Camera.CameraInfo(); + Camera.getCameraInfo(i, info); + if (info.facing == targetFacing) + return i; + } + + throw new CommandException("No " + facing + " camera found on this device"); + } +} diff --git a/sensorhub-driver-android/src/main/java/org/sensorhub/impl/sensor/android/AndroidSensorsDriver.java b/sensorhub-driver-android/src/main/java/org/sensorhub/impl/sensor/android/AndroidSensorsDriver.java index 2fb5f6c..18f680e 100644 --- a/sensorhub-driver-android/src/main/java/org/sensorhub/impl/sensor/android/AndroidSensorsDriver.java +++ b/sensorhub-driver-android/src/main/java/org/sensorhub/impl/sensor/android/AndroidSensorsDriver.java @@ -43,6 +43,7 @@ import org.sensorhub.impl.sensor.android.audio.AndroidAudioOutputAAC; import org.sensorhub.impl.sensor.android.audio.AndroidAudioOutputOPUS; import org.sensorhub.impl.sensor.android.audio.AudioEncoderConfig; +import org.sensorhub.impl.sensor.android.video.AndroidCameraOutput; import org.sensorhub.impl.sensor.android.video.AndroidCameraOutputH264; import org.sensorhub.impl.sensor.android.video.AndroidCameraOutputH265; import org.sensorhub.impl.sensor.android.video.AndroidCameraOutputMJPEG; @@ -77,6 +78,7 @@ public class AndroidSensorsDriver extends AbstractSensorModule smlComponents; + AndroidCameraOutput currentCameraOutput; public AndroidSensorsDriver() @@ -148,8 +150,14 @@ protected synchronized void doInit() throws SensorHubException // create data interfaces for cameras if (androidContext.getPackageManager().hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY)) + { createCameraOutputs(androidContext); + // register camera control (front/back switching + zoom) + if (currentCameraOutput != null) + addControlInput(new AndroidCameraControl(this)); + } + // create data interfaces for audio if (androidContext.getPackageManager().hasSystemFeature(PackageManager.FEATURE_MICROPHONE)) createAudioOutputs(androidContext); @@ -273,6 +281,8 @@ protected void useCamera(IStreamingDataInterface output, int cameraId) { addOutput(output, false); smlComponents.add(smlBuilder.getComponentDescription(cameraId)); + if (output instanceof AndroidCameraOutput) + currentCameraOutput = (AndroidCameraOutput) output; log.info("Getting data from camera #" + cameraId); } @@ -292,7 +302,27 @@ protected void useAudio(IStreamingDataInterface output, String srcName) log.info("Getting data from audio source " + srcName); } - + + public void switchCamera(int newCameraId) throws SensorException + { + if (currentCameraOutput == null) + throw new SensorException("No camera output to switch"); + + currentCameraOutput.switchCamera(newCameraId); + config.selectedCameraId = newCameraId; + log.info("Switched to camera #{}", newCameraId); + } + + + public void setCameraZoom(int zoomLevel) throws SensorException + { + if (currentCameraOutput == null) + throw new SensorException("No camera output to set zoom on"); + + currentCameraOutput.setZoom(zoomLevel); + } + + @Override protected void doStop() throws SensorException { diff --git a/sensorhub-driver-android/src/main/java/org/sensorhub/impl/sensor/android/video/AndroidCameraOutput.java b/sensorhub-driver-android/src/main/java/org/sensorhub/impl/sensor/android/video/AndroidCameraOutput.java index 8cae659..5b5148c 100644 --- a/sensorhub-driver-android/src/main/java/org/sensorhub/impl/sensor/android/video/AndroidCameraOutput.java +++ b/sensorhub-driver-android/src/main/java/org/sensorhub/impl/sensor/android/video/AndroidCameraOutput.java @@ -420,6 +420,85 @@ public void onAccuracyChanged(Sensor sensor, int accuracy) { } + public void switchCamera(int newCameraId) throws SensorException + { + if (newCameraId == this.cameraId) + return; + + if (camera != null) + { + camera.stopPreview(); + camera.setPreviewCallbackWithBuffer(null); + camera.release(); + camera = null; + } + + if (mCodec != null) + { + mCodec.stop(); + mCodec.release(); + mCodec = null; + } + + if (bgLooper != null) + { + bgLooper.quit(); + bgLooper = null; + } + + codecInfoData = null; + + this.cameraId = newCameraId; + initCam(); + initCodec(); + + if (mCodec != null) + mCodec.start(); + + try + { + if (previewTexture != null) + camera.setPreviewTexture(previewTexture); + camera.startPreview(); + } + catch (Exception e) + { + throw new SensorException("Cannot restart camera preview after switch", e); + } + } + + + public void setZoom(int zoomLevel) throws SensorException + { + if (camera == null) + throw new SensorException("Camera is not initialized"); + if (bgLooper == null) + throw new SensorException("Camera looper is not running"); + + new Handler(bgLooper).post(() -> { + try + { + Camera.Parameters params = camera.getParameters(); + if (!params.isZoomSupported()) + { + log.warn("Zoom is not supported on camera {}", cameraId); + return; + } + + int maxZoom = params.getMaxZoom(); + int clampedZoom = Math.max(0, Math.min(zoomLevel, maxZoom)); + params.setZoom(clampedZoom); + camera.setParameters(params); + log.info("Zoom set to {}/{} on camera #{}", clampedZoom, maxZoom, cameraId); + } + catch (Exception e) + { + log.error("Failed to set zoom on camera {}", cameraId, e); + } + }); + } + + @Override public void stop() {