Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions app/android/src/uk/co/lutraconsulting/CameraActivity.java
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@
import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.util.Log;
import android.provider.MediaStore;
import android.graphics.Bitmap;
Expand All @@ -40,6 +42,8 @@
public class CameraActivity extends Activity {
private static final String TAG = "Camera Activity";
private static final int CAMERA_CODE = 102;
private static final String KEY_TARGET_PATH = "targetPath";
private static final String KEY_CAMERA_FILE_PATH = "cameraFilePath";

private String targetPath;
private File cameraFile;
Expand All @@ -58,6 +62,15 @@ protected void onCreate(Bundle savedInstanceState) {
orientationSensor = new OrientationSensor(mSensorManager, null);
orientationSensor.Register(this, SensorManager.SENSOR_DELAY_NORMAL);

if (savedInstanceState != null && savedInstanceState.containsKey(KEY_CAMERA_FILE_PATH)) {
// Process was killed and recreated while the camera app held the foreground.
// The capture is already in flight -- resume instead of relaunching it.
targetPath = savedInstanceState.getString(KEY_TARGET_PATH);
cameraFile = new File(savedInstanceState.getString(KEY_CAMERA_FILE_PATH));
Log.d(TAG, "Resumed after process recreation, cameraFile: " + cameraFile.getAbsolutePath());
return;
}

targetPath = getIntent().getExtras().getString("targetPath");
Log.d(TAG, "targetPath: " + targetPath);

Expand All @@ -77,7 +90,22 @@ protected void onCreate(Bundle savedInstanceState) {
photoFile);

takePictureIntent.putExtra(MediaStore.EXTRA_OUTPUT, photoURI);
takePictureIntent.setFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION
| Intent.FLAG_GRANT_READ_URI_PERMISSION);

// Explicitly grant URI permission to every app that can resolve this intent.
// Required because the URI is passed via EXTRA_OUTPUT rather than setData(),
// and some OEM camera apps (confirmed: Motorola) don't reliably honor the
// FLAG_GRANT_* flags in that case.
List<ResolveInfo> resolvedActivities = getPackageManager()
.queryIntentActivities(takePictureIntent, PackageManager.MATCH_DEFAULT_ONLY);
for (ResolveInfo resolveInfo : resolvedActivities) {
grantUriPermission(resolveInfo.activityInfo.packageName, photoURI,
Intent.FLAG_GRANT_WRITE_URI_PERMISSION | Intent.FLAG_GRANT_READ_URI_PERMISSION);
}

takePictureIntent.putExtra("__RESULT__", "takePictureIntent__RESULT__");
startForegroundService(new Intent(this, CameraForegroundService.class));
startActivityForResult(takePictureIntent, CAMERA_CODE);
} else {
Intent activityIntent = getIntent();
Expand All @@ -90,6 +118,15 @@ protected void onCreate(Bundle savedInstanceState) {
return;
}

@Override
protected void onSaveInstanceState(Bundle outState) {
super.onSaveInstanceState(outState);
outState.putString(KEY_TARGET_PATH, targetPath);
if (cameraFile != null) {
outState.putString(KEY_CAMERA_FILE_PATH, cameraFile.getAbsolutePath());
}
}

private File createImageFile(String targetPath) throws IOException {
// Create an image file name
String currentPhotoPath;
Expand All @@ -115,7 +152,20 @@ protected void onActivityResult(int requestCode, int resultCode, Intent data) {
Log.d(TAG, "resultCode: " + resultCode);
orientationSensor.Unregister();

if (requestCode == CAMERA_CODE) {
// no-op if it isn't running (e.g. process was killed and recreated), stops it either way
stopService(new Intent(this, CameraForegroundService.class));
}

if (requestCode == CAMERA_CODE && resultCode == Activity.RESULT_OK) {
if (cameraFile == null) {
Log.e(TAG, "cameraFile is null in onActivityResult - lost capture state.");
Intent resultData = getIntent();
resultData.putExtra("__RESULT__", "Lost photo capture state.");
setResult(Activity.RESULT_CANCELED, resultData);
finish();
return;
}
Log.d(TAG, "tmp exists: " + cameraFile.exists());
Log.d(TAG, "tmp path: " + cameraFile.getAbsolutePath());

Expand All @@ -142,6 +192,23 @@ protected void onActivityResult(int requestCode, int resultCode, Intent data) {
finish();
}

@Override
protected void onDestroy() {
super.onDestroy();
// no-op if it was already stopped in onActivityResult() or never started
stopService(new Intent(this, CameraForegroundService.class));
if (cameraFile != null) {
try {
Uri photoURI = FileProvider.getUriForFile(this,
"uk.co.lutraconsulting.fileprovider", cameraFile);
revokeUriPermission(photoURI,
Intent.FLAG_GRANT_WRITE_URI_PERMISSION | Intent.FLAG_GRANT_READ_URI_PERMISSION);
} catch (IllegalArgumentException e) {
// cameraFile isn't covered by file_paths.xml -- nothing was granted, nothing to revoke
}
}
}

private void extendGPSExifData(long captureTime) {
int direction = getValueByTime(orientationSensor.m_azimuth_data, captureTime);
if (direction < 0) {
Expand Down
73 changes: 73 additions & 0 deletions app/android/src/uk/co/lutraconsulting/CameraForegroundService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/***************************************************************************
* *
* This program is free software; you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation; either version 2 of the License, or *
* (at your option) any later version. *
* *
***************************************************************************/

package uk.co.lutraconsulting;

import android.os.Build;
import android.os.IBinder;
import android.app.Service;
import android.app.PendingIntent;
import android.content.Intent;
import android.content.pm.ServiceInfo;

import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;

/**
* Runs in the app's default process (unlike PositionTrackingService, which deliberately
* runs in its own :trackingThread) for as long as CameraActivity is waiting on the external
* camera app, to raise this process's priority and make it much less likely to be picked by
* the OS's low-memory killer while the (often heavy) OEM camera stack is in the foreground.
* This is a mitigation, not a guarantee -- under severe enough memory pressure the process can
* still be killed, which is what CameraActivity's saved-instance-state resume handles.
*/
public class CameraForegroundService extends Service {

private static final String CHANNEL_ID = "CameraForegroundServiceChannel";
private static final int SERVICE_ID = 1011;

@Override
public IBinder onBind(Intent intent) {
return null;
}

@Override
public int onStartCommand(Intent intent, int flags, int startId) {
NotificationChannel serviceChannel = new NotificationChannel(
CHANNEL_ID,
"Camera Foreground Service Channel",
NotificationManager.IMPORTANCE_LOW
);

NotificationManager manager = getSystemService(NotificationManager.class);
manager.createNotificationChannel(serviceChannel);

Intent notificationIntent = new Intent(this, MMActivity.class);
PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE);

Notification notification = new Notification.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle("Waiting for photo")
.setColor(getResources().getColor(R.color.grassColor))
.setContentIntent(pendingIntent)
.build();

if (Build.VERSION.SDK_INT >= 35) { // Android 15 (Vanilla Ice Cream) -- FOREGROUND_SERVICE_TYPE_SHORT_SERVICE
startForeground(SERVICE_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_SHORT_SERVICE);
} else {
startForeground(SERVICE_ID, notification);
}

// Not START_STICKY: if this service alone gets killed there is nothing useful to resume --
// it holds no state, it only exists to keep the process's priority elevated while
// CameraActivity waits on startActivityForResult().
return START_NOT_STICKY;
}
}
Loading