diff --git a/app/android/src/uk/co/lutraconsulting/CameraActivity.java b/app/android/src/uk/co/lutraconsulting/CameraActivity.java index dc16ad4da..ff691f932 100644 --- a/app/android/src/uk/co/lutraconsulting/CameraActivity.java +++ b/app/android/src/uk/co/lutraconsulting/CameraActivity.java @@ -78,6 +78,7 @@ protected void onCreate(Bundle savedInstanceState) { takePictureIntent.putExtra(MediaStore.EXTRA_OUTPUT, photoURI); takePictureIntent.putExtra("__RESULT__", "takePictureIntent__RESULT__"); + startForegroundService(new Intent(this, CameraForegroundService.class)); startActivityForResult(takePictureIntent, CAMERA_CODE); } else { Intent activityIntent = getIntent(); @@ -115,6 +116,11 @@ 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) { Log.d(TAG, "tmp exists: " + cameraFile.exists()); Log.d(TAG, "tmp path: " + cameraFile.getAbsolutePath()); @@ -142,6 +148,13 @@ 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)); + } + private void extendGPSExifData(long captureTime) { int direction = getValueByTime(orientationSensor.m_azimuth_data, captureTime); if (direction < 0) { diff --git a/app/android/src/uk/co/lutraconsulting/CameraForegroundService.java b/app/android/src/uk/co/lutraconsulting/CameraForegroundService.java new file mode 100644 index 000000000..0f65ce12e --- /dev/null +++ b/app/android/src/uk/co/lutraconsulting/CameraForegroundService.java @@ -0,0 +1,77 @@ +/*************************************************************************** + * * + * 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 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 killed while + * the camera is in the foreground. + * This is a mitigation, not a guarantee -- under severe enough memory pressure the process can + * still be killed; CameraActivity does not currently resume state after that, so a kill mid-wait + * causes the camera capture to restart from scratch on the recreated process. + */ +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(); + + // We never request the POST_NOTIFICATIONS runtime permission from the user, so this + // notification will silently not be shown unless the user has manually enabled it for + // the app in system Settings. startForeground() still succeeds and still elevates the + // process's priority either way -- the permission only gates the notification's + // visibility, not the foreground service state itself. + if (Build.VERSION.SDK_INT >= 35) { + startForeground(SERVICE_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_SHORT_SERVICE); + } else { + startForeground(SERVICE_ID, notification); + } + + // if this service alone gets killed there is nothing useful to resume, it only exists to + // keep the process's priority elevated while CameraActivity waits on startActivityForResult(). + return START_NOT_STICKY; + } +} diff --git a/cmake_templates/AndroidManifest.xml.in b/cmake_templates/AndroidManifest.xml.in index 147c6025b..c474618de 100644 --- a/cmake_templates/AndroidManifest.xml.in +++ b/cmake_templates/AndroidManifest.xml.in @@ -104,6 +104,13 @@ android:stopWithTask="true" android:exported="false" /> + +