diff --git a/app/build.gradle b/app/build.gradle index 09f9aff..dea5edb 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -46,7 +46,7 @@ dependencies { androidTestImplementation('androidx.test.espresso:espresso-core:3.1.0', { exclude group: 'com.android.support', module: 'support-annotations' }) - def camerax_version = "1.2.3" + def camerax_version = "1.4.2" implementation "androidx.camera:camera-core:$camerax_version" implementation "androidx.camera:camera-camera2:$camerax_version" implementation "androidx.camera:camera-lifecycle:$camerax_version" diff --git a/app/src/main/java/com/blackboxembedded/WunderLINQ/PhotoService.java b/app/src/main/java/com/blackboxembedded/WunderLINQ/PhotoService.java index 8b8fa72..fb0383f 100644 --- a/app/src/main/java/com/blackboxembedded/WunderLINQ/PhotoService.java +++ b/app/src/main/java/com/blackboxembedded/WunderLINQ/PhotoService.java @@ -18,31 +18,21 @@ package com.blackboxembedded.WunderLINQ; import static android.content.Intent.FLAG_ACTIVITY_NEW_TASK; -import static java.lang.Math.abs; -import android.Manifest; import android.app.Service; -import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; -import android.content.pm.PackageManager; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.graphics.Matrix; import android.location.Criteria; import android.location.Location; import android.location.LocationManager; -import androidx.exifinterface.media.ExifInterface; import android.media.MediaScannerConnection; -import android.os.Bundle; import android.os.Environment; import android.os.IBinder; -import android.preference.PreferenceManager; import android.text.format.DateFormat; import android.util.Log; -import android.util.SparseIntArray; -import android.view.Surface; -import android.view.WindowManager; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -52,10 +42,11 @@ import androidx.camera.core.ImageProxy; import androidx.camera.lifecycle.ProcessCameraProvider; import androidx.core.content.ContextCompat; +import androidx.exifinterface.media.ExifInterface; import androidx.lifecycle.Lifecycle; import androidx.lifecycle.LifecycleOwner; import androidx.lifecycle.LifecycleRegistry; -import androidx.localbroadcastmanager.content.LocalBroadcastManager; +import androidx.preference.PreferenceManager; import com.google.common.util.concurrent.ListenableFuture; @@ -63,219 +54,233 @@ import java.io.FileOutputStream; import java.io.IOException; import java.nio.ByteBuffer; +import java.text.SimpleDateFormat; import java.util.Date; +import java.util.Locale; import java.util.concurrent.ExecutionException; public class PhotoService extends Service implements LifecycleOwner { private static final String TAG = "PhotoService"; - private LifecycleRegistry mLifecycleRegistry; - private int cameraArg = 0; - private ImageCapture imageCapture; - private File file; - private Location location; - - private static final SparseIntArray ORIENTATIONS = new SparseIntArray(); - static { - ORIENTATIONS.append(Surface.ROTATION_0, 90); - ORIENTATIONS.append(Surface.ROTATION_90, 0); - ORIENTATIONS.append(Surface.ROTATION_180, 270); - ORIENTATIONS.append(Surface.ROTATION_270, 180); - } - @Nullable - @Override - public IBinder onBind(Intent intent) { - return null; - } + // Intent extras you already use: + // "CAMERA" (int: 0 front, 1 back), "PREFIX" (String), etc. + private int cameraArg = 1; // default back + private String prefixArg = "IMG_"; + + private LifecycleRegistry lifecycleRegistry; + private ImageCapture imageCapture; + @Nullable private Location location; @Override public void onCreate() { - Log.d(TAG, "onCreate"); super.onCreate(); - mLifecycleRegistry = new LifecycleRegistry(this); - mLifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE); + lifecycleRegistry = new LifecycleRegistry(this); + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE); } @Override - public int onStartCommand(Intent intent, int flags, int startId) { - Log.d(TAG, "onStartCommand"); - mLifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START); - - if (intent != null) { - // Retrieve the Intent that started the Service - Bundle extras = intent.getExtras(); - if (extras != null) { - cameraArg = extras.getInt("camera"); - Log.d(TAG, "Camera Choice: " + cameraArg); - } else { - stopSelf(); + public int onStartCommand(@Nullable Intent intent, int flags, int startId) { + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START); + + if (intent != null && intent.getExtras() != null) { + cameraArg = intent.getIntExtra("CAMERA", 1); + String p = intent.getStringExtra("PREFIX"); + if (p != null && !p.isEmpty()) prefixArg = p; + } + + // Prepare last-known location if app has permission. + boolean hasFine = checkSelfPermission(android.Manifest.permission.ACCESS_FINE_LOCATION) + == android.content.pm.PackageManager.PERMISSION_GRANTED; + boolean hasCoarse = checkSelfPermission(android.Manifest.permission.ACCESS_COARSE_LOCATION) + == android.content.pm.PackageManager.PERMISSION_GRANTED; + if (hasFine || hasCoarse) { + try { + LocationManager lm = (LocationManager) getSystemService(LOCATION_SERVICE); + if (lm != null) { + Criteria c = new Criteria(); + String provider = lm.getBestProvider(c, false); + if (provider != null) location = lm.getLastKnownLocation(provider); + } + } catch (Throwable t) { + Log.w(TAG, "Unable to get last known location", t); } } - ListenableFuture cameraProviderFuture = ProcessCameraProvider.getInstance(this); + // Bind camera and capture one image. + ListenableFuture cameraProviderFuture = + ProcessCameraProvider.getInstance(this); + cameraProviderFuture.addListener(() -> { try { ProcessCameraProvider cameraProvider = cameraProviderFuture.get(); cameraProvider.unbindAll(); - CameraSelector cameraSelector = new CameraSelector.Builder() - .requireLensFacing(CameraSelector.LENS_FACING_BACK) - .build(); - if (cameraArg == 0){ - cameraSelector = new CameraSelector.Builder() - .requireLensFacing(CameraSelector.LENS_FACING_FRONT) - .build(); - } - WindowManager windowService = (WindowManager) getSystemService(Context.WINDOW_SERVICE); - int rotation = windowService.getDefaultDisplay().getRotation(); - Log.d(TAG,"rotation: " + rotation); + + CameraSelector selector = (cameraArg == 0) + ? new CameraSelector.Builder() + .requireLensFacing(CameraSelector.LENS_FACING_FRONT).build() + : new CameraSelector.Builder() + .requireLensFacing(CameraSelector.LENS_FACING_BACK).build(); + imageCapture = new ImageCapture.Builder() - //.setTargetRotation(Surface.ROTATION_270) + // In 1.4.0, target rotation on headless capture isn’t required; + // we’ll rotate via the image’s metadata when saving. + .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY) .build(); - cameraProvider.bindToLifecycle(this, cameraSelector, imageCapture); - // Take a picture - takePicture(); + + cameraProvider.bindToLifecycle(this, selector, imageCapture); + takePicture(); // single shot then stopSelf() } catch (ExecutionException | InterruptedException e) { - e.printStackTrace(); + Log.e(TAG, "Failed to get camera provider", e); + stopSelf(); } }, ContextCompat.getMainExecutor(this)); - boolean locationWPPerms = getApplication().checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED; - // Check Location permissions - if (locationWPPerms) { - LocationManager locationManager = (LocationManager) - this.getSystemService(LOCATION_SERVICE); - Criteria criteria = new Criteria(); - String bestProvider = locationManager.getBestProvider(criteria, false); - location = locationManager.getLastKnownLocation(bestProvider); - } - return START_NOT_STICKY; } - @Override - public void onDestroy() { - super.onDestroy(); - mLifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY); - } - - @NonNull - @Override - public Lifecycle getLifecycle() { - return mLifecycleRegistry; - } - private void takePicture() { - imageCapture.takePicture(ContextCompat.getMainExecutor(this), new ImageCapture.OnImageCapturedCallback() { - @Override - public void onCaptureSuccess(@NonNull ImageProxy image) { - super.onCaptureSuccess(image); - // Get the bitmap from the image - Bitmap bitmap = imageProxyToBitmap(image); - - // Save the bitmap to file - file = createFile(); - try { - FileOutputStream fos = new FileOutputStream(file); - bitmap.compress(Bitmap.CompressFormat.JPEG, 100, fos); - fos.close(); - Log.d(TAG, "File saved: " + file.getAbsolutePath()); - - if (location != null) { - Log.d(TAG,"Location: " + location.toString()); - storeGeoCoordsToImage(file, location); + imageCapture.takePicture(ContextCompat.getMainExecutor(this), + new ImageCapture.OnImageCapturedCallback() { + @Override + public void onCaptureSuccess(@NonNull ImageProxy image) { + try { + // 1) Decode JPEG plane + Bitmap bmp = imageProxyToBitmap(image); + + // 2) Rotate according to metadata + int rotationDegrees = image.getImageInfo().getRotationDegrees(); + if (rotationDegrees != 0) { + Matrix m = new Matrix(); + m.postRotate(rotationDegrees); + bmp = Bitmap.createBitmap(bmp, 0, 0, bmp.getWidth(), bmp.getHeight(), m, true); + } + + // 3) Save to file + File file = saveBitmapToPictures(bmp); + + // 4) Write EXIF GPS (if available) + if (file != null && location != null) { + try { + ExifInterface exif = new ExifInterface(file.getAbsolutePath()); + exif.setAttribute(ExifInterface.TAG_GPS_LATITUDE, + getLatGeoCoordinates(location)); + exif.setAttribute(ExifInterface.TAG_GPS_LATITUDE_REF, + location.getLatitude() < 0 ? "S" : "N"); + exif.setAttribute(ExifInterface.TAG_GPS_LONGITUDE, + getLonGeoCoordinates(location)); + exif.setAttribute(ExifInterface.TAG_GPS_LONGITUDE_REF, + location.getLongitude() < 0 ? "W" : "E"); + exif.saveAttributes(); + } catch (IOException e) { + Log.w(TAG, "EXIF write failed", e); + } + } + + // 5) Media scan & optional preview + if (file != null) { + MediaScannerConnection.scanFile(PhotoService.this, + new String[]{file.toString()}, null, null); + + SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(getApplicationContext()); + if (sp.getBoolean("prefPhotoPreview", false)) { + Intent alert = new Intent(getApplicationContext(), AlertActivity.class); + alert.setFlags(FLAG_ACTIVITY_NEW_TASK); + alert.putExtra("TYPE", AlertActivity.ALERT_PHOTO); + alert.putExtra("TITLE", getString(R.string.alert_title_photopreview)); + alert.putExtra("BODY", ""); + alert.putExtra("BACKGROUND", file.getAbsolutePath()); + startActivity(alert); + } + } + } catch (Throwable t) { + Log.e(TAG, "Error handling captured image", t); + } finally { + // Always close the image! + image.close(); + stopSelf(); + } } - SharedPreferences sharedPrefs = PreferenceManager.getDefaultSharedPreferences(MyApplication.getContext()); - if (sharedPrefs.getBoolean("prefPhotoPreview",false)) { - Intent alertIntent = new Intent(MyApplication.getContext(), AlertActivity.class); - alertIntent.setFlags(FLAG_ACTIVITY_NEW_TASK); - alertIntent.putExtra("TYPE", AlertActivity.ALERT_PHOTO); - alertIntent.putExtra("TITLE", MyApplication.getContext().getResources().getString(R.string.alert_title_photopreview)); - alertIntent.putExtra("BODY", ""); - alertIntent.putExtra("BACKGROUND", file.getAbsolutePath()); - MyApplication.getContext().startActivity(alertIntent); + @Override + public void onError(@NonNull ImageCaptureException exception) { + Log.e(TAG, "Error taking picture", exception); + stopSelf(); } - - MediaScannerConnection.scanFile(PhotoService.this, - new String[] { file.toString() }, null, - (path, uri) -> { - Log.i(TAG, "Scanned file: " + path); - stopSelf(); - }); - // Send a broadcast to notify the picture has been taken - Intent pictureTakenIntent = new Intent("PICTURE_TAKEN"); - pictureTakenIntent.putExtra("file_path", file.getAbsolutePath()); - LocalBroadcastManager.getInstance(PhotoService.this).sendBroadcast(pictureTakenIntent); - } catch (IOException e) { - Log.d(TAG, "Error Saving: "); - e.printStackTrace(); - stopSelf(); - } - } - - @Override - public void onError(@NonNull ImageCaptureException exception) { - super.onError(exception); - Log.e(TAG, "Error taking picture", exception); - stopSelf(); - } - }); + }); } - private Bitmap imageProxyToBitmap(ImageProxy image) { + private static Bitmap imageProxyToBitmap(@NonNull ImageProxy image) { + // Assumes JPEG output (default for ImageCapture). Plane[0] contains the full JPEG bytes. ByteBuffer buffer = image.getPlanes()[0].getBuffer(); buffer.rewind(); - byte[] bytes = new byte[buffer.capacity()]; + byte[] bytes = new byte[buffer.remaining()]; buffer.get(bytes); - Bitmap bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.length); - // Rotate bitmap if necessary - Matrix matrix = new Matrix(); - matrix.postRotate((float)image.getImageInfo().getRotationDegrees()); - Bitmap bitmap2 = Bitmap.createBitmap(bitmap, 0, 0, bitmap.getWidth(), bitmap.getHeight(), matrix, true); - return bitmap2; + return BitmapFactory.decodeByteArray(bytes, 0, bytes.length); } - private File createFile() { - File root = new File( Environment.getExternalStoragePublicDirectory( - Environment.DIRECTORY_DCIM), "/WunderLINQ/"); - if(!root.exists()){ - if(!root.mkdirs()){ - Log.d(TAG,"Unable to create directory: " + root); - } + @Nullable + private File saveBitmapToPictures(@NonNull Bitmap bmp) { + String dirName = Environment.DIRECTORY_PICTURES; + File pictures = Environment.getExternalStoragePublicDirectory(dirName); + File appDir = new File(pictures, "WunderLINQ"); + if (!appDir.exists() && !appDir.mkdirs()) { + Log.e(TAG, "Failed to create directory: " + appDir); + return null; + } + + String ts = new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(new Date()); + String name = prefixArg + DateFormat.format("yyyyMMdd_HHmmss", System.currentTimeMillis()) + "_" + ts + ".jpg"; + File out = new File(appDir, name); + + FileOutputStream fos = null; + try { + fos = new FileOutputStream(out); + bmp.compress(Bitmap.CompressFormat.JPEG, 95, fos); + fos.flush(); + return out; + } catch (IOException e) { + Log.e(TAG, "Saving bitmap failed", e); + return null; + } finally { + if (fos != null) try { fos.close(); } catch (IOException ignored) {} } - return new File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM) + "/WunderLINQ/" - + "IMG_" + DateFormat.format("yyyyMMdd_kkmmss", new Date().getTime()) + ".jpg"); } - public static String getLatGeoCoordinates(Location location) { + // --- EXIF helpers (unchanged logic) --- + private static String getLatGeoCoordinates(Location loc) { + double lat = Math.abs(loc.getLatitude()); + int deg = (int) lat; + lat = (lat - deg) * 60; + int min = (int) lat; + double sec = (lat - min) * 60; + return deg + "/1," + min + "/1," + (int) (sec * 1000) + "/1000"; + } - if (location == null) return "0/1,0/1,0/1000"; - String[] degMinSec = Location.convert(abs(location.getLatitude()), Location.FORMAT_SECONDS).split(":"); - return degMinSec[0] + "/1," + degMinSec[1] + "/1," + degMinSec[2] + "/1000"; + private static String getLonGeoCoordinates(Location loc) { + double lon = Math.abs(loc.getLongitude()); + int deg = (int) lon; + lon = (lon - deg) * 60; + int min = (int) lon; + double sec = (lon - min) * 60; + return deg + "/1," + min + "/1," + (int) (sec * 1000) + "/1000"; } - public static String getLonGeoCoordinates(Location location) { + // --- LifecycleOwner for camera binding --- + @NonNull + @Override + public Lifecycle getLifecycle() { + return lifecycleRegistry; + } - if (location == null) return "0/1,0/1,0/1000"; - String[] degMinSec = Location.convert(abs(location.getLongitude()), Location.FORMAT_SECONDS).split(":"); - return degMinSec[0] + "/1," + degMinSec[1] + "/1," + degMinSec[2] + "/1000"; + @Override + public void onDestroy() { + super.onDestroy(); + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY); } - public static boolean storeGeoCoordsToImage(File imagePath, Location location) { - // Avoid NullPointer - if (imagePath == null || location == null) return false; - try { - ExifInterface exif = new ExifInterface(imagePath.getAbsolutePath()); - exif.setAttribute(ExifInterface.TAG_GPS_LATITUDE, getLatGeoCoordinates(location)); - exif.setAttribute(ExifInterface.TAG_GPS_LATITUDE_REF, location.getLatitude() < 0 ? "S" : "N"); - exif.setAttribute(ExifInterface.TAG_GPS_LONGITUDE, getLonGeoCoordinates(location)); - exif.setAttribute(ExifInterface.TAG_GPS_LONGITUDE_REF, location.getLongitude() < 0 ? "W" : "E"); - Log.d(TAG,exif.toString()); - exif.saveAttributes(); - } catch (IOException e) { - e.printStackTrace(); - return false; - } - return true; + @Nullable + @Override + public IBinder onBind(Intent intent) { + return null; // started service } } diff --git a/app/src/main/java/com/blackboxembedded/WunderLINQ/TaskList/TaskActivity.java b/app/src/main/java/com/blackboxembedded/WunderLINQ/TaskList/TaskActivity.java index 4873314..c1feb49 100644 --- a/app/src/main/java/com/blackboxembedded/WunderLINQ/TaskList/TaskActivity.java +++ b/app/src/main/java/com/blackboxembedded/WunderLINQ/TaskList/TaskActivity.java @@ -592,7 +592,7 @@ private void executeTask(int taskID){ Toast.makeText(TaskActivity.this, R.string.toast_permission_denied, Toast.LENGTH_LONG).show(); } else { Intent photoIntent = new Intent(TaskActivity.this, PhotoService.class); - photoIntent.putExtra("camera", CameraCharacteristics.LENS_FACING_BACK); + photoIntent.putExtra("CAMERA", CameraCharacteristics.LENS_FACING_BACK); startService(photoIntent); } break; @@ -603,7 +603,7 @@ private void executeTask(int taskID){ Toast.makeText(TaskActivity.this, R.string.toast_permission_denied, Toast.LENGTH_LONG).show(); } else { Intent photoIntent = new Intent(TaskActivity.this, PhotoService.class); - photoIntent.putExtra("camera", CameraCharacteristics.LENS_FACING_FRONT); + photoIntent.putExtra("CAMERA", CameraCharacteristics.LENS_FACING_FRONT); startService(photoIntent); } break; diff --git a/app/src/main/java/com/blackboxembedded/WunderLINQ/VideoRecService.java b/app/src/main/java/com/blackboxembedded/WunderLINQ/VideoRecService.java index 7a36329..7a6ab4b 100644 --- a/app/src/main/java/com/blackboxembedded/WunderLINQ/VideoRecService.java +++ b/app/src/main/java/com/blackboxembedded/WunderLINQ/VideoRecService.java @@ -24,33 +24,35 @@ import android.app.NotificationManager; import android.app.Service; import android.content.ContentValues; -import android.content.Context; import android.content.Intent; import android.content.pm.PackageManager; -import android.hardware.camera2.CameraAccessException; -import android.hardware.camera2.CameraCharacteristics; -import android.hardware.camera2.CameraManager; import android.location.Criteria; import android.location.Location; import android.location.LocationManager; -import android.media.MediaRecorder; +import android.net.Uri; import android.os.Build; -import android.os.Bundle; import android.os.Environment; -import android.os.Handler; import android.os.IBinder; -import android.os.Looper; import android.provider.MediaStore; import android.text.format.DateFormat; import android.util.Log; -import android.util.Size; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.camera.core.AspectRatio; import androidx.camera.core.CameraSelector; -import androidx.camera.core.VideoCapture; import androidx.camera.lifecycle.ProcessCameraProvider; +import androidx.camera.video.FallbackStrategy; +import androidx.camera.video.FileOutputOptions; +import androidx.camera.video.MediaStoreOutputOptions; +import androidx.camera.video.PendingRecording; +import androidx.camera.video.Quality; +import androidx.camera.video.QualitySelector; +import androidx.camera.video.Recorder; +import androidx.camera.video.Recording; +import androidx.camera.video.VideoCapture; +import androidx.camera.video.VideoRecordEvent; +import androidx.camera.video.QualitySelector; +import androidx.camera.video.Quality; import androidx.core.app.NotificationCompat; import androidx.core.content.ContextCompat; import androidx.lifecycle.Lifecycle; @@ -60,264 +62,261 @@ import com.google.common.util.concurrent.ListenableFuture; import java.io.File; -import java.io.IOException; +import java.text.SimpleDateFormat; import java.util.Date; +import java.util.Locale; import java.util.concurrent.ExecutionException; -import java.util.concurrent.Executor; public class VideoRecService extends Service implements LifecycleOwner { - private static final String TAG = "VideoRecService"; - private LifecycleRegistry mLifecycleRegistry; - private int cameraArg = 0; - private CameraManager cameraManager; - private MediaRecorder mediaRecorder; - private String cameraId; - private Size videoSize; + // Foreground notification + private static final String CHANNEL_ID = "wlq-video"; + private static final int NOTIF_ID = 1234; + + // Intent extras (match your existing usage) + // CAMERA: 0=front, 1=back (default back) + private int cameraArg = CameraSelector.LENS_FACING_BACK; + + private LifecycleRegistry lifecycleRegistry; private ProcessCameraProvider cameraProvider; - private File outputFile; - private Location location; - private boolean isRecording = false; + + // CameraX 1.4.0 video API + private Recorder recorder; + private VideoCapture videoCapture; + private Recording activeRecording; + + // Optional: last known location (for MediaStore LAT/LON columns) + @Nullable private Location location; @Override public void onCreate() { super.onCreate(); - mLifecycleRegistry = new LifecycleRegistry(this); - mLifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE); - + lifecycleRegistry = new LifecycleRegistry(this); + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE); createNotification(); + } - cameraManager = (CameraManager) getSystemService(Context.CAMERA_SERVICE); - try { - for (String id : cameraManager.getCameraIdList()) { - CameraCharacteristics characteristics = cameraManager.getCameraCharacteristics(id); - if (characteristics.get(CameraCharacteristics.LENS_FACING) == cameraArg) { - cameraId = id; - break; - } + @Override + public int onStartCommand(@Nullable Intent intent, int flags, int startId) { + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START); + + if (intent != null) { + cameraArg = intent.getIntExtra("CAMERA", CameraSelector.LENS_FACING_BACK); + } + + // Try to fetch a last-known location if we have permission. + fetchLastKnownLocation(); + + // Spin up CameraX and start recording + ListenableFuture providerFuture = ProcessCameraProvider.getInstance(this); + providerFuture.addListener(() -> { + try { + cameraProvider = providerFuture.get(); + startCameraAndRecord(); + } catch (ExecutionException | InterruptedException e) { + Log.e(TAG, "CameraProvider error", e); + stopSelf(); } - } catch (CameraAccessException e) { - Log.e(TAG, "Failed to get camera ID", e); + }, ContextCompat.getMainExecutor(this)); + + return START_STICKY; + } + + private void startCameraAndRecord() { + if (cameraProvider == null) { + Log.e(TAG, "cameraProvider null"); stopSelf(); + return; } - if (cameraId == null) { - Log.e(TAG, "No camera found"); - stopSelf(); + cameraProvider.unbindAll(); + + CameraSelector selector = new CameraSelector.Builder() + .requireLensFacing(cameraArg == 0 + ? CameraSelector.LENS_FACING_FRONT + : CameraSelector.LENS_FACING_BACK) + .build(); + + // Prefer FHD, then HD, then SD (fallbacks are important across devices) + QualitySelector qualitySelector = QualitySelector.fromOrderedList( + java.util.Arrays.asList(Quality.FHD, Quality.HD, Quality.SD), + FallbackStrategy.lowerQualityOrHigherThan(Quality.FHD)); + + recorder = new Recorder.Builder() + .setQualitySelector(qualitySelector) + .build(); + + videoCapture = VideoCapture.withOutput(recorder); + + // Bind to this Service's lifecycle + cameraProvider.bindToLifecycle(this, selector, videoCapture); + + // Choose output: MediaStore (scoped storage) for API 29+; else a file in Movies/WunderLINQ + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startRecordingToMediaStore(); + } else { + startRecordingToFile(); + } + } + + private void startRecordingToMediaStore() { + String displayName = "WLQ_" + new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(new Date()); + ContentValues values = new ContentValues(); + values.put(MediaStore.MediaColumns.DISPLAY_NAME, displayName); + values.put(MediaStore.MediaColumns.MIME_TYPE, "video/mp4"); + values.put(MediaStore.Video.Media.TITLE, "WunderLINQ Video"); + if (location != null) { + // These columns are respected by some OEM galleries and Google Photos for videos. + values.put(MediaStore.Video.Media.LATITUDE, location.getLatitude()); + values.put(MediaStore.Video.Media.LONGITUDE, location.getLongitude()); } - mediaRecorder = new MediaRecorder(); + MediaStoreOutputOptions outputOptions = + new MediaStoreOutputOptions.Builder(getContentResolver(), + MediaStore.Video.Media.EXTERNAL_CONTENT_URI) + .setContentValues(values) + .build(); + + beginRecording(outputOptions); } - @SuppressLint("RestrictedApi") - @Override - public int onStartCommand(Intent intent, int flags, int startId) { - Log.d(TAG, "onStartCommand"); - mLifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START); + private void startRecordingToFile() { + File movies = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES); + File appDir = new File(movies, "WunderLINQ"); + if (!appDir.exists() && !appDir.mkdirs()) { + Log.e(TAG, "Failed to create dir: " + appDir); + stopSelf(); + return; + } + String ts = DateFormat.format("yyyyMMdd_HHmmss", System.currentTimeMillis()).toString(); + File out = new File(appDir, "WLQ_" + ts + ".mp4"); - if (intent != null) { - // Retrieve the Intent that started the Service - Bundle extras = intent.getExtras(); - if (extras != null) { - cameraArg = extras.getInt("camera"); - Log.d(TAG, "Camera Choice: " + cameraArg); - } else { - stopSelf(); - } + FileOutputOptions outputOptions = + new FileOutputOptions.Builder(out).build(); + + beginRecording(outputOptions); + } + + @SuppressLint("MissingPermission") + private void beginRecording(@NonNull Object outputOptions) { + if (videoCapture == null || recorder == null) { + Log.e(TAG, "Video components not ready"); + stopSelf(); + return; } - boolean locationWPPerms = getApplication().checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED; - // Check Location permissions - if (locationWPPerms) { - LocationManager locationManager = (LocationManager) - this.getSystemService(LOCATION_SERVICE); - Criteria criteria = new Criteria(); - String bestProvider = locationManager.getBestProvider(criteria, false); - location = locationManager.getLastKnownLocation(bestProvider); + PendingRecording pending; + if (outputOptions instanceof MediaStoreOutputOptions) { + pending = recorder.prepareRecording(this, (MediaStoreOutputOptions) outputOptions); + } else if (outputOptions instanceof FileOutputOptions) { + pending = recorder.prepareRecording(this, (FileOutputOptions) outputOptions); + } else { + Log.e(TAG, "Unsupported output options"); + stopSelf(); + return; } - cameraManager = (CameraManager) getSystemService(Context.CAMERA_SERVICE); + // Enable audio if we have permission + boolean hasAudio = ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) + == PackageManager.PERMISSION_GRANTED; + if (hasAudio) pending = pending.withAudioEnabled(); - // Get the camera instance - ListenableFuture cameraProviderFuture = ProcessCameraProvider.getInstance(this); - cameraProviderFuture.addListener(() -> { - try { - // Set up the video output file - outputFile = createVideoFile(); - - // Set up the MediaRecorder - Size[] sizes = cameraManager.getCameraCharacteristics(cameraId) - .get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP) - .getOutputSizes(MediaRecorder.class); - - videoSize = sizes[0]; - for (Size size : sizes) { - if (size.getWidth() * size.getHeight() > videoSize.getWidth() * videoSize.getHeight()) { - videoSize = size; - } - } - mediaRecorder = new MediaRecorder(); - mediaRecorder.setVideoSource(MediaRecorder.VideoSource.SURFACE); - mediaRecorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4); - mediaRecorder.setOutputFile(outputFile.getAbsolutePath()); - mediaRecorder.setVideoEncodingBitRate(10000000); - mediaRecorder.setAudioSamplingRate(16000); - mediaRecorder.setVideoFrameRate(30); - mediaRecorder.setVideoSize(videoSize.getWidth(), videoSize.getHeight()); - mediaRecorder.setVideoEncoder(MediaRecorder.VideoEncoder.H264); - mediaRecorder.prepare(); - - // Get the camera provider instance - cameraProvider = cameraProviderFuture.get(); - - cameraProvider.unbindAll(); - - CameraSelector cameraSelector = new CameraSelector.Builder() - .requireLensFacing(CameraSelector.LENS_FACING_BACK) - .build(); - if (cameraArg == 0 ){ - cameraSelector = new CameraSelector.Builder() - .requireLensFacing(CameraSelector.LENS_FACING_FRONT) - .build(); - } - - // Set up the video capture use case - VideoCapture.Builder videoCaptureConfigBuilder = new VideoCapture.Builder(); - videoCaptureConfigBuilder.setTargetAspectRatio(AspectRatio.RATIO_16_9); - VideoCapture videoCapture = videoCaptureConfigBuilder.build(); - - // Bind the lifecycle of the camera to the lifecycle of the service - cameraProvider.bindToLifecycle(this, cameraSelector, videoCapture); - - ContentValues contentValues = new ContentValues(); - contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, "WunderLINQ-" + DateFormat.format("yyyyMMdd_kkmmss", new Date().getTime()).toString()); - contentValues.put(MediaStore.MediaColumns.MIME_TYPE, "video/mp4"); - contentValues.put(MediaStore.Video.Media.TITLE, "WunderLINQ Video"); - if (location != null) { - contentValues.put(MediaStore.Video.Media.LATITUDE, String.valueOf(location.getLatitude())); - contentValues.put(MediaStore.Video.Media.LONGITUDE, String.valueOf(location.getLongitude())); - } - - VideoCapture.OutputFileOptions outputFileOptions = new VideoCapture.OutputFileOptions.Builder( - this.getContentResolver(), - MediaStore.Video.Media.EXTERNAL_CONTENT_URI, //Use this to save in normal Gallery - contentValues - ).build(); - - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - videoCapture.startRecording(outputFileOptions, getMainExecutor(), new VideoCapture.OnVideoSavedCallback() { - @Override - public void onVideoSaved(@NonNull VideoCapture.OutputFileResults outputFileResults) { - Log.i(TAG,"Recording ended"); - } - - @Override - public void onError(int videoCaptureError, String message, Throwable cause) { - // Error occurred while saving the video - } - }); - } else { - Handler mainHandler = new Handler(Looper.getMainLooper()); - Executor mainExecutor = new Executor() { - @Override - public void execute(Runnable command) { - mainHandler.post(command); - } - }; - - videoCapture.startRecording(outputFileOptions, mainExecutor, new VideoCapture.OnVideoSavedCallback() { - @Override - public void onVideoSaved(@NonNull VideoCapture.OutputFileResults outputFileResults) { - Log.i(TAG,"Recording ended"); - } - - @Override - public void onError(int videoCaptureError, String message, Throwable cause) { - // Error occurred while saving the video - } - }); - } - - // Start recording - mediaRecorder.start(); - isRecording = true; + activeRecording = pending.start(ContextCompat.getMainExecutor(this), event -> { + if (event instanceof VideoRecordEvent.Start) { Log.d(TAG, "Recording started"); - ((MyApplication) this.getApplication()).setVideoRecording(true); - - } catch (ExecutionException | InterruptedException | IOException | CameraAccessException e) { - Log.e(TAG, "Error setting up camera and media recorder", e); + ((MyApplication) getApplication()).setVideoRecording(true); + } else if (event instanceof VideoRecordEvent.Finalize finalizeEvent) { + Uri uri = finalizeEvent.getOutputResults().getOutputUri(); + Log.d(TAG, "Recording finalized: " + uri + " error=" + finalizeEvent.getError()); + ((MyApplication) getApplication()).setVideoRecording(false); + // Stop the service once finalized (adjust if you want continuous) stopSelf(); + } else if (event instanceof VideoRecordEvent.Status status) { + // Optional: bitrate, duration, etc. + // Log.v(TAG, "Status: " + status.getRecordedDurationNanos()); + } else if (event instanceof VideoRecordEvent.Pause) { + Log.d(TAG, "Recording paused"); + } else if (event instanceof VideoRecordEvent.Resume) { + Log.d(TAG, "Recording resumed"); } - }, ContextCompat.getMainExecutor(this)); + }); + } - // Return START_STICKY to indicate that this service should be restarted if it's killed - return START_STICKY; + private void fetchLastKnownLocation() { + boolean fine = ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) + == PackageManager.PERMISSION_GRANTED; + boolean coarse = ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) + == PackageManager.PERMISSION_GRANTED; + if (!fine && !coarse) return; + + try { + LocationManager lm = (LocationManager) getSystemService(LOCATION_SERVICE); + if (lm != null) { + Criteria c = new Criteria(); + String provider = lm.getBestProvider(c, false); + if (provider != null) location = lm.getLastKnownLocation(provider); + } + } catch (Throwable t) { + Log.w(TAG, "Location fetch failed", t); + } } @Override public void onDestroy() { Log.d(TAG, "onDestroy"); - mLifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY); - if (isRecording) { - // Stop recording + // Stop recording if active + if (activeRecording != null) { try { - mediaRecorder.stop(); - } catch(RuntimeException stopException) { - // handle cleanup here + activeRecording.stop(); + } catch (Throwable t) { + Log.w(TAG, "Error stopping recording", t); } - mediaRecorder.reset(); - mediaRecorder.release(); - Log.d(TAG, "Recording stopped"); + try { + activeRecording.close(); + } catch (Throwable ignored) {} + activeRecording = null; } // Unbind and release the camera if (cameraProvider != null) { cameraProvider.unbindAll(); + cameraProvider = null; } - ((MyApplication) this.getApplication()).setVideoRecording(false); + ((MyApplication) getApplication()).setVideoRecording(false); + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY); super.onDestroy(); } - @Nullable - @Override - public IBinder onBind(Intent intent) { - return null; + // --- Foreground notification (unchanged style) --- + private void createNotification() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + NotificationChannel ch = new NotificationChannel( + CHANNEL_ID, + getString(R.string.title_video_notification), + NotificationManager.IMPORTANCE_DEFAULT + ); + ch.setShowBadge(false); + ch.setSound(null, null); + ((NotificationManager) getSystemService(NOTIFICATION_SERVICE)).createNotificationChannel(ch); + } + Notification notif = new NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle(getString(R.string.title_video_notification)) + .setContentText("") + .setSmallIcon(R.drawable.ic_video_camera) + .build(); + startForeground(NOTIF_ID, notif); } - @NonNull - @Override + // --- LifecycleOwner for binding --- + @NonNull @Override public Lifecycle getLifecycle() { - return mLifecycleRegistry; - } - - private File createVideoFile() throws IOException { - return new File(Environment.getExternalStoragePublicDirectory( - Environment.DIRECTORY_DCIM)+"/WunderLINQ/VID_"+ - DateFormat.format("yyyyMMdd_kkmmss", new Date().getTime())+ - ".mp4"); + return lifecycleRegistry; } - private void createNotification() { - // Start foreground service to avoid unexpected kill - String CHANNEL_ID = "WunderLINQ"; - NotificationChannel channel = new NotificationChannel(CHANNEL_ID, this.getString(R.string.title_video_notification), - NotificationManager.IMPORTANCE_DEFAULT); - channel.setShowBadge(false); - channel.setSound(null, null); - NotificationManager manager = (NotificationManager) getSystemService(NOTIFICATION_SERVICE); - manager.createNotificationChannel(channel); - - NotificationCompat.Builder builder = new NotificationCompat.Builder(this) - .setChannelId(CHANNEL_ID) - .setContentTitle(getResources().getString(R.string.title_video_notification)) - .setContentText("") - .setSmallIcon(R.drawable.ic_video_camera); - Notification notification = builder.build(); - startForeground(1234, notification); - } -} \ No newline at end of file + @Nullable @Override + public IBinder onBind(Intent intent) { return null; } +}