Repository: google/ground-android
Issue: Cleared photo reappears on rotating device 

**Describe the bug**
The photo response reappears even after removing it manually. This only happens if the screen is rotated after removing the photo.

**To Reproduce**
Steps to reproduce the behavior:
1. Select any project which contains a photo field
2. Create a new observation and click on add photo
3. Select a new photo 
4. The selected/captured photo should be visible in the edit fragment.
5. Now, remove the photo and rotate the screen.

**Expected behavior**
Removed photo should not reappear

**Actual behavior**
We can see that the photo is visible.


Changed files:
gnd/src/main/java/com/google/android/gnd/repository/UserMediaRepository.java
gnd/src/test/java/com/google/android/gnd/system/CameraManagerTest.java
gnd/src/main/java/com/google/android/gnd/ui/editobservation/EditObservationViewModel.java
gnd/src/main/java/com/google/android/gnd/ui/editobservation/EditObservationFragment.java
gnd/src/main/java/com/google/android/gnd/persistence/remote/firestore/FirestoreStorageManager.java
gnd/src/main/java/com/google/android/gnd/system/StorageManager.java
gnd/src/main/java/com/google/android/gnd/system/CameraManager.java
gnd/src/main/java/com/google/android/gnd/ui/editobservation/PhotoFieldViewModel.java

diff --git a/gnd/src/main/java/com/google/android/gnd/persistence/remote/firestore/FirestoreStorageManager.java b/gnd/src/main/java/com/google/android/gnd/persistence/remote/firestore/FirestoreStorageManager.java
index 42b5fcbe3..aed32051b 100644
--- a/gnd/src/main/java/com/google/android/gnd/persistence/remote/firestore/FirestoreStorageManager.java
+++ b/gnd/src/main/java/com/google/android/gnd/persistence/remote/firestore/FirestoreStorageManager.java
@@ -17,7 +17,6 @@
 package com.google.android.gnd.persistence.remote.firestore;
 
 import android.net.Uri;
-import com.google.android.gnd.model.observation.Observation;
 import com.google.android.gnd.persistence.remote.RemoteStorageManager;
 import com.google.android.gnd.persistence.remote.TransferProgress;
 import com.google.android.gnd.rx.RxTask;
@@ -46,19 +45,19 @@ public class FirestoreStorageManager implements RemoteStorageManager {
   FirestoreStorageManager() {}
 
   /**
-   * Generates destination path in which an observation attachement is to be stored in to Cloud
+   * Generates destination path in which an observation attachment is to be stored in to Cloud
    * Storage.
    *
    * <p>user-media/projects/{project_id}/observations/{observation_id}/{field_id-uuid.jpg}
    */
-  public static String getRemoteMediaPath(Observation observation, String filename) {
+  public static String getRemoteMediaPath(String projectId, String observationId, String filename) {
     // TODO: Refactor this into MediaStorageRepository.
     return new StringJoiner(File.separator)
         .add(MEDIA_ROOT_DIR)
         .add("projects")
-        .add(observation.getProject().getId())
+        .add(projectId)
         .add("observations")
-        .add(observation.getId())
+        .add(observationId)
         .add(filename)
         .toString();
   }
diff --git a/gnd/src/main/java/com/google/android/gnd/repository/UserMediaRepository.java b/gnd/src/main/java/com/google/android/gnd/repository/UserMediaRepository.java
index 2bb277ad7..9d43c6766 100644
--- a/gnd/src/main/java/com/google/android/gnd/repository/UserMediaRepository.java
+++ b/gnd/src/main/java/com/google/android/gnd/repository/UserMediaRepository.java
@@ -22,7 +22,6 @@ import android.net.Uri;
 import android.os.Environment;
 import android.provider.MediaStore;
 import com.google.android.gnd.Config;
-import com.google.android.gnd.model.form.Field;
 import com.google.android.gnd.persistence.remote.RemoteStorageManager;
 import com.google.android.gnd.persistence.uuid.OfflineUuidGenerator;
 import com.google.android.gnd.rx.annotations.Cold;
@@ -61,12 +60,12 @@ public class UserMediaRepository {
     return context.getExternalFilesDir(Environment.DIRECTORY_PICTURES);
   }
 
-  public String createImageFilename(Field field) {
-    return field.getId() + "-" + uuidGenerator.generateUuid() + Config.PHOTO_EXT;
+  public String createImageFilename(String fieldId) {
+    return fieldId + "-" + uuidGenerator.generateUuid() + Config.PHOTO_EXT;
   }
 
-  public File createImageFile(Field field) {
-    return new File(getRootDir(), createImageFilename(field));
+  public File createImageFile(String fieldId) {
+    return new File(getRootDir(), createImageFilename(fieldId));
   }
 
   /**
@@ -74,8 +73,8 @@ public class UserMediaRepository {
    *
    * @throws IOException If path is not accessible or error occurs while saving file
    */
-  public File savePhoto(Bitmap bitmap, Field field) throws IOException {
-    File file = createImageFile(field);
+  public File savePhoto(Bitmap bitmap, String fieldId) throws IOException {
+    File file = createImageFile(fieldId);
     try (FileOutputStream fos = new FileOutputStream(file)) {
       bitmap.compress(Bitmap.CompressFormat.JPEG, 100, fos);
     }
diff --git a/gnd/src/main/java/com/google/android/gnd/system/CameraManager.java b/gnd/src/main/java/com/google/android/gnd/system/CameraManager.java
deleted file mode 100644
index bd14cd1ef..000000000
--- a/gnd/src/main/java/com/google/android/gnd/system/CameraManager.java
+++ /dev/null
@@ -1,106 +0,0 @@
-/*
- * Copyright 2020 Google LLC
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *     https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.google.android.gnd.system;
-
-import android.Manifest.permission;
-import android.content.Context;
-import android.content.Intent;
-import android.net.Uri;
-import android.provider.MediaStore;
-import androidx.core.content.FileProvider;
-import com.google.android.gnd.BuildConfig;
-import com.google.android.gnd.rx.Nil;
-import com.google.android.gnd.rx.annotations.Cold;
-import com.google.android.gnd.rx.annotations.Hot;
-import dagger.hilt.android.qualifiers.ApplicationContext;
-import io.reactivex.Completable;
-import io.reactivex.Maybe;
-import java.io.File;
-import javax.inject.Inject;
-import javax.inject.Singleton;
-import timber.log.Timber;
-
-/** Manages permissions needed for using camera and related flows to/from Activity. */
-@Singleton
-public class CameraManager {
-
-  /** Used to identify requests coming from this application. */
-  static final int CAPTURE_PHOTO_REQUEST_CODE = CameraManager.class.hashCode() & 0xffff;
-
-  private final Context context;
-  private final PermissionsManager permissionsManager;
-  private final ActivityStreams activityStreams;
-
-  @Inject
-  public CameraManager(
-      @ApplicationContext Context context,
-      PermissionsManager permissionsManager,
-      ActivityStreams activityStreams) {
-    this.context = context;
-    this.permissionsManager = permissionsManager;
-    this.activityStreams = activityStreams;
-  }
-
-  /** Launches the system's photo capture flow, first obtaining permissions if necessary. */
-  @Cold
-  public Maybe<Nil> capturePhoto(File destFile) {
-    return permissionsManager
-        .obtainPermission(permission.WRITE_EXTERNAL_STORAGE)
-        .andThen(permissionsManager.obtainPermission(permission.CAMERA))
-        .andThen(sendCapturePhotoIntent(destFile))
-        .andThen(capturePhotoResult());
-  }
-
-  /** Enqueue an intent for capturing a photo. */
-  @Cold
-  private Completable sendCapturePhotoIntent(File photoFile) {
-    return Completable.fromAction(
-        () ->
-            activityStreams.withActivity(
-                activity -> {
-                  Intent cameraIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
-                  Uri photoUri =
-                      FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID, photoFile);
-                  cameraIntent.putExtra(MediaStore.EXTRA_OUTPUT, photoUri);
-                  activity.startActivityForResult(cameraIntent, CAPTURE_PHOTO_REQUEST_CODE);
-                  Timber.v("Capture photo intent sent. Image path: %s", photoFile);
-                }));
-  }
-
-  /** Emits the result of the photo capture request. */
-  @Hot(terminates = true)
-  Maybe<Nil> capturePhotoResult() {
-    return activityStreams
-        .getNextActivityResult(CAPTURE_PHOTO_REQUEST_CODE)
-        .flatMapMaybe(this::onCapturePhotoResult)
-        .singleElement();
-  }
-
-  /** Returns success if the result is ok. */
-  @Cold
-  private Maybe<Nil> onCapturePhotoResult(ActivityResult result) {
-    Timber.v("Photo result returned");
-    return Maybe.create(
-        emitter -> {
-          if (result.isOk()) {
-            emitter.onSuccess(Nil.NIL);
-          } else {
-            emitter.onComplete();
-          }
-        });
-  }
-}
diff --git a/gnd/src/main/java/com/google/android/gnd/system/StorageManager.java b/gnd/src/main/java/com/google/android/gnd/system/StorageManager.java
index 6e2ba15f5..1e66b2e29 100644
--- a/gnd/src/main/java/com/google/android/gnd/system/StorageManager.java
+++ b/gnd/src/main/java/com/google/android/gnd/system/StorageManager.java
@@ -21,6 +21,7 @@ import android.content.Intent;
 import android.graphics.Bitmap;
 import android.net.Uri;
 import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
 import com.google.android.gnd.rx.annotations.Cold;
 import com.google.android.gnd.rx.annotations.Hot;
 import com.google.android.gnd.ui.util.BitmapUtil;
@@ -35,6 +36,7 @@ import timber.log.Timber;
 @Singleton
 public class StorageManager {
 
+  @VisibleForTesting
   static final int PICK_PHOTO_REQUEST_CODE = StorageManager.class.hashCode() & 0xffff;
 
   private final PermissionsManager permissionsManager;
diff --git a/gnd/src/main/java/com/google/android/gnd/ui/editobservation/EditObservationFragment.java b/gnd/src/main/java/com/google/android/gnd/ui/editobservation/EditObservationFragment.java
index 3d39daade..be19f73f5 100644
--- a/gnd/src/main/java/com/google/android/gnd/ui/editobservation/EditObservationFragment.java
+++ b/gnd/src/main/java/com/google/android/gnd/ui/editobservation/EditObservationFragment.java
@@ -31,6 +31,9 @@ import android.view.View;
 import android.view.ViewGroup;
 import android.view.inputmethod.InputMethodManager;
 import android.widget.LinearLayout;
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.activity.result.contract.ActivityResultContracts.GetContent;
+import androidx.activity.result.contract.ActivityResultContracts.TakePicturePreview;
 import androidx.annotation.Nullable;
 import androidx.appcompat.app.AlertDialog;
 import androidx.databinding.ViewDataBinding;
@@ -58,6 +61,7 @@ import com.google.android.gnd.ui.common.BackPressListener;
 import com.google.android.gnd.ui.common.EphemeralPopups;
 import com.google.android.gnd.ui.common.Navigator;
 import com.google.android.gnd.ui.common.TwoLineToolbar;
+import com.google.android.gnd.ui.util.BitmapUtil;
 import com.google.android.material.bottomsheet.BottomSheetDialog;
 import com.google.common.collect.ImmutableList;
 import dagger.hilt.android.AndroidEntryPoint;
@@ -74,10 +78,19 @@ import timber.log.Timber;
 @AndroidEntryPoint
 public class EditObservationFragment extends AbstractFragment implements BackPressListener {
 
-  private static final String RESTORED_RESPONSES_BUNDLE_KEY = "restoredResponses";
+  /** String constant keys used for persisting state in {@see Bundle} objects. */
+  private static final class BundleKeys {
+
+    /** Key used to store unsaved responses across activity re-creation. */
+    private static final String RESTORED_RESPONSES = "restoredResponses";
+
+    /** Key used to store field ID waiting for photo response across activity re-creation. */
+    private static final String FIELD_WAITING_FOR_PHOTO = "photoFieldId";
+  }
 
   private final List<AbstractFieldViewModel> fieldViewModelList = new ArrayList<>();
 
+  @Inject BitmapUtil bitmapUtil;
   @Inject Navigator navigator;
   @Inject FieldViewFactory fieldViewFactory;
   @Inject EphemeralPopups popups;
@@ -86,6 +99,9 @@ public class EditObservationFragment extends AbstractFragment implements BackPre
   private EditObservationViewModel viewModel;
   private EditObservationFragBinding binding;
 
+  private ActivityResultLauncher<String> selectPhotoLauncher;
+  private ActivityResultLauncher<Void> capturePhotoLauncher;
+
   private static AbstractFieldViewModel getViewModel(ViewDataBinding binding) {
     if (binding instanceof TextInputFieldBinding) {
       return ((TextInputFieldBinding) binding).getViewModel();
@@ -108,6 +124,10 @@ public class EditObservationFragment extends AbstractFragment implements BackPre
   public void onCreate(@Nullable Bundle savedInstanceState) {
     super.onCreate(savedInstanceState);
     viewModel = getViewModel(EditObservationViewModel.class);
+    selectPhotoLauncher =
+        registerForActivityResult(new GetContent(), viewModel::onSelectPhotoResult);
+    capturePhotoLauncher =
+        registerForActivityResult(new TakePicturePreview(), viewModel::onCapturePhotoResult);
   }
 
   @Override
@@ -139,8 +159,10 @@ public class EditObservationFragment extends AbstractFragment implements BackPre
     Bundle args = getArguments();
     if (savedInstanceState != null) {
       args.putSerializable(
-          RESTORED_RESPONSES_BUNDLE_KEY,
-          savedInstanceState.getSerializable(RESTORED_RESPONSES_BUNDLE_KEY));
+          BundleKeys.RESTORED_RESPONSES,
+          savedInstanceState.getSerializable(BundleKeys.RESTORED_RESPONSES));
+      viewModel.setFieldWaitingForPhoto(
+          savedInstanceState.getString(BundleKeys.FIELD_WAITING_FOR_PHOTO));
     }
     viewModel.initialize(EditObservationFragmentArgs.fromBundle(args));
   }
@@ -148,7 +170,8 @@ public class EditObservationFragment extends AbstractFragment implements BackPre
   @Override
   public void onSaveInstanceState(Bundle outState) {
     super.onSaveInstanceState(outState);
-    outState.putSerializable(RESTORED_RESPONSES_BUNDLE_KEY, viewModel.getDraftResponses());
+    outState.putSerializable(BundleKeys.RESTORED_RESPONSES, viewModel.getDraftResponses());
+    outState.putString(BundleKeys.FIELD_WAITING_FOR_PHOTO, viewModel.getFieldWaitingForPhoto());
   }
 
   private void handleSaveResult(EditObservationViewModel.SaveResult saveResult) {
@@ -289,8 +312,10 @@ public class EditObservationFragment extends AbstractFragment implements BackPre
 
   private void initPhotoField(PhotoFieldViewModel photoFieldViewModel) {
     photoFieldViewModel.setEditable(true);
+    photoFieldViewModel.setProjectId(viewModel.getProjectId());
+    photoFieldViewModel.setObservationId(viewModel.getObservationId());
     observeSelectPhotoClicks(photoFieldViewModel);
-    observePhotoAdded(photoFieldViewModel);
+    observePhotoResults(photoFieldViewModel);
   }
 
   private void observeSelectPhotoClicks(PhotoFieldViewModel fieldViewModel) {
@@ -299,18 +324,11 @@ public class EditObservationFragment extends AbstractFragment implements BackPre
         .observe(this, __ -> onShowPhotoSelectorDialog(fieldViewModel.getField()));
   }
 
-  private void observePhotoAdded(PhotoFieldViewModel fieldViewModel) {
+  private void observePhotoResults(PhotoFieldViewModel fieldViewModel) {
     viewModel
-        .getPhotoFieldUpdates()
-        .observe(
-            this,
-            map -> {
-              // TODO: Do not set response if already handled.
-              Field field = fieldViewModel.getField();
-              if (map.containsKey(field)) {
-                fieldViewModel.updateResponse(map.get(field));
-              }
-            });
+        .getLastPhotoResult()
+        .as(autoDisposable(getViewLifecycleOwner()))
+        .subscribe(fieldViewModel::onPhotoResult);
   }
 
   private void onShowPhotoSelectorDialog(Field field) {
@@ -378,16 +396,34 @@ public class EditObservationFragment extends AbstractFragment implements BackPre
   private void onSelectPhotoClick(int type, Field field) {
     switch (type) {
       case PHOTO_SOURCE_CAMERA:
-        viewModel.showPhotoCapture(field);
+        viewModel
+            .obtainCapturePhotoPermissions()
+            .as(autoDisposable(getViewLifecycleOwner()))
+            .subscribe(() -> launchPhotoCapture(field.getId()));
         break;
       case PHOTO_SOURCE_STORAGE:
-        viewModel.showPhotoSelector(field);
+        viewModel
+            .obtainSelectPhotoPermissions()
+            .as(autoDisposable(getViewLifecycleOwner()))
+            .subscribe(() -> launchPhotoSelector(field.getId()));
         break;
       default:
         throw new IllegalArgumentException("Unknown type: " + type);
     }
   }
 
+  private void launchPhotoCapture(String fieldId) {
+    viewModel.setFieldWaitingForPhoto(fieldId);
+    capturePhotoLauncher.launch(null);
+    Timber.d("Capture photo intent sent");
+  }
+
+  private void launchPhotoSelector(String fieldId) {
+    viewModel.setFieldWaitingForPhoto(fieldId);
+    selectPhotoLauncher.launch("image/*");
+    Timber.d("Select photo intent sent");
+  }
+
   @Override
   public boolean onBack() {
     if (viewModel.hasUnsavedChanges()) {
diff --git a/gnd/src/main/java/com/google/android/gnd/ui/editobservation/EditObservationViewModel.java b/gnd/src/main/java/com/google/android/gnd/ui/editobservation/EditObservationViewModel.java
index 08ee9c516..f890a7076 100644
--- a/gnd/src/main/java/com/google/android/gnd/ui/editobservation/EditObservationViewModel.java
+++ b/gnd/src/main/java/com/google/android/gnd/ui/editobservation/EditObservationViewModel.java
@@ -16,11 +16,14 @@
 
 package com.google.android.gnd.ui.editobservation;
 
+import static android.Manifest.permission.CAMERA;
+import static android.Manifest.permission.READ_EXTERNAL_STORAGE;
+import static android.Manifest.permission.WRITE_EXTERNAL_STORAGE;
 import static androidx.lifecycle.LiveDataReactiveStreams.fromPublisher;
-import static com.google.android.gnd.persistence.remote.firestore.FirestoreStorageManager.getRemoteMediaPath;
-import static com.google.common.base.Preconditions.checkNotNull;
 
 import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.net.Uri;
 import androidx.lifecycle.LiveData;
 import androidx.lifecycle.MutableLiveData;
 import com.google.android.gnd.R;
@@ -33,22 +36,23 @@ import com.google.android.gnd.model.observation.Response;
 import com.google.android.gnd.model.observation.ResponseDelta;
 import com.google.android.gnd.model.observation.ResponseMap;
 import com.google.android.gnd.repository.ObservationRepository;
-import com.google.android.gnd.repository.UserMediaRepository;
 import com.google.android.gnd.rx.Nil;
 import com.google.android.gnd.rx.annotations.Cold;
 import com.google.android.gnd.rx.annotations.Hot;
-import com.google.android.gnd.system.CameraManager;
-import com.google.android.gnd.system.StorageManager;
+import com.google.android.gnd.system.PermissionsManager;
 import com.google.android.gnd.ui.common.AbstractViewModel;
+import com.google.android.gnd.ui.util.BitmapUtil;
+import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMap;
 import io.reactivex.Completable;
 import io.reactivex.Observable;
 import io.reactivex.Single;
 import io.reactivex.processors.BehaviorProcessor;
 import io.reactivex.processors.FlowableProcessor;
 import io.reactivex.processors.PublishProcessor;
-import java.io.File;
+import io.reactivex.subjects.BehaviorSubject;
+import io.reactivex.subjects.Subject;
+import java.io.IOException;
 import java.io.Serializable;
 import java.util.HashMap;
 import java.util.Map;
@@ -63,9 +67,8 @@ public class EditObservationViewModel extends AbstractViewModel {
 
   private final ObservationRepository observationRepository;
   private final Resources resources;
-  private final UserMediaRepository userMediaRepository;
-  private final StorageManager storageManager;
-  private final CameraManager cameraManager;
+  private final PermissionsManager permissionsManager;
+  private final BitmapUtil bitmapUtil;
 
   // States.
 
@@ -101,6 +104,13 @@ public class EditObservationViewModel extends AbstractViewModel {
   /** True if the observation is being added, false if editing an existing one. */
   private boolean isNew;
 
+  /**
+   * Emits the last photo field id updated and either its photo result, or empty if removed. The
+   * last value is emitted on each subscription because {@see #onPhotoResult} is called before
+   * subscribers are created.
+   */
+  private Subject<PhotoResult> lastPhotoResult = BehaviorSubject.create();
+
   // Events.
 
   /** "Save" button clicks. */
@@ -109,22 +119,18 @@ public class EditObservationViewModel extends AbstractViewModel {
   /** Outcome of user clicking "Save". */
   private final Observable<SaveResult> saveResults;
 
-  /** Stream of updates to photo fields. */
-  @Hot(replays = true)
-  private final MutableLiveData<ImmutableMap<Field, String>> photoUpdates = new MutableLiveData<>();
+  @Nullable private String fieldWaitingForPhoto;
 
   @Inject
   EditObservationViewModel(
       Resources resources,
       ObservationRepository observationRepository,
-      UserMediaRepository userMediaRepository,
-      StorageManager storageManager,
-      CameraManager cameraManager) {
+      PermissionsManager permissionsManager,
+      BitmapUtil bitmapUtil) {
     this.resources = resources;
     this.observationRepository = observationRepository;
-    this.userMediaRepository = userMediaRepository;
-    this.storageManager = storageManager;
-    this.cameraManager = cameraManager;
+    this.permissionsManager = permissionsManager;
+    this.bitmapUtil = bitmapUtil;
     this.form = fromPublisher(viewArgs.switchMapSingle(this::onInitialize));
     this.saveResults = saveClicks.toObservable().switchMapSingle(__ -> onSave());
   }
@@ -145,6 +151,14 @@ public class EditObservationViewModel extends AbstractViewModel {
     return saveResults;
   }
 
+  public @Nullable String getProjectId() {
+    return originalObservation == null ? null : originalObservation.getProject().getId();
+  }
+
+  public @Nullable String getObservationId() {
+    return originalObservation == null ? null : originalObservation.getId();
+  }
+
   void initialize(EditObservationFragmentArgs args) {
     viewArgs.onNext(args);
   }
@@ -162,55 +176,16 @@ public class EditObservationViewModel extends AbstractViewModel {
         r -> responses.put(field.getId(), r), () -> responses.remove(field.getId()));
   }
 
-  public void showPhotoSelector(Field field) {
-    /*
-     * Didn't subscribe this with Fragment's lifecycle because we need to retain the disposable
-     * after the fragment is destroyed (for activity result)
-     */
-    // TODO: launch intent through fragment and handle activity result callbacks async
-    disposeOnClear(
-        storageManager
-            .selectPhoto()
-            .doOnError(Timber::e) // TODO(#726): Display as a toast
-            .map(bitmap -> userMediaRepository.savePhoto(bitmap, field))
-            .flatMapCompletable(file -> onPhotoSaved(field, file))
-            .subscribe());
-  }
-
-  public void showPhotoCapture(Field field) {
-    File imageFile = userMediaRepository.createImageFile(field);
-
-    /*
-     * Didn't subscribe this with Fragment's lifecycle because we need to retain the disposable
-     * after the fragment is destroyed (for activity result)
-     */
-    // TODO: launch intent through fragment and handle activity result callbacks async
-    disposeOnClear(
-        cameraManager
-            .capturePhoto(imageFile)
-            .doOnError(Timber::e) // TODO(#726): Display as a toast
-            .flatMapCompletable(__ -> onPhotoSaved(field, imageFile))
-            .subscribe());
-  }
-
   @Cold
-  private Completable onPhotoSaved(Field field, File imageFile) {
-    return Completable.fromAction(
-        () -> {
-          String filename = imageFile.getName();
-
-          // Add image to gallery
-          userMediaRepository.addImageToGallery(imageFile.getAbsolutePath(), filename);
-
-          // Update response
-          checkNotNull(originalObservation);
-          String remoteDestinationPath = getRemoteMediaPath(originalObservation, filename);
-          photoUpdates.postValue(ImmutableMap.of(field, remoteDestinationPath));
-        });
+  public Completable obtainCapturePhotoPermissions() {
+    return permissionsManager
+        .obtainPermission(WRITE_EXTERNAL_STORAGE)
+        .andThen(permissionsManager.obtainPermission(CAMERA));
   }
 
-  LiveData<ImmutableMap<Field, String>> getPhotoFieldUpdates() {
-    return photoUpdates;
+  @Cold
+  public Completable obtainSelectPhotoPermissions() {
+    return permissionsManager.obtainPermission(READ_EXTERNAL_STORAGE);
   }
 
   public void onSaveClick(Map<String, String> validationErrors) {
@@ -336,9 +311,53 @@ public class EditObservationViewModel extends AbstractViewModel {
   }
 
   public Serializable getDraftResponses() {
-    HashMap<String, Response> hashMap = new HashMap<>();
-    hashMap.putAll(responses);
-    return hashMap;
+    return new HashMap<>(responses);
+  }
+
+  @Nullable
+  public String getFieldWaitingForPhoto() {
+    return fieldWaitingForPhoto;
+  }
+
+  public void setFieldWaitingForPhoto(@Nullable String fieldWaitingForPhoto) {
+    this.fieldWaitingForPhoto = fieldWaitingForPhoto;
+  }
+
+  public Observable<PhotoResult> getLastPhotoResult() {
+    return lastPhotoResult;
+  }
+
+  public void onSelectPhotoResult(Uri uri) {
+    if (uri == null) {
+      Timber.e("onSelectPhotoResult called with null uri");
+      return;
+    }
+    try {
+      Bitmap bitmap = bitmapUtil.fromUri(uri);
+      onPhotoResult(bitmap);
+      Timber.v("Select photo result returned");
+    } catch (IOException e) {
+      Timber.e(e, "Error getting photo returned by camera");
+    }
+  }
+
+  public void onCapturePhotoResult(Bitmap thumbnail) {
+    if (thumbnail == null) {
+      Timber.e("onCapturePhotoResult called with null thumbnail");
+      return;
+    }
+    onPhotoResult(thumbnail);
+    Timber.v("Photo capture result returned");
+  }
+
+  private void onPhotoResult(Bitmap bitmap) {
+    if (fieldWaitingForPhoto == null) {
+      Timber.e("Photo received but no field waiting for result");
+      return;
+    }
+    String fieldId = fieldWaitingForPhoto;
+    fieldWaitingForPhoto = null;
+    lastPhotoResult.onNext(PhotoResult.create(fieldId, Optional.of(bitmap)));
   }
 
   /** Possible outcomes of user clicking "Save". */
@@ -347,4 +366,15 @@ public class EditObservationViewModel extends AbstractViewModel {
     NO_CHANGES_TO_SAVE,
     SAVED
   }
+
+  @AutoValue
+  abstract static class PhotoResult {
+    abstract String getFieldId();
+
+    abstract Optional<Bitmap> getBitmap();
+
+    static PhotoResult create(String fieldId, Optional<Bitmap> bitmap) {
+      return new AutoValue_EditObservationViewModel_PhotoResult(fieldId, bitmap);
+    }
+  }
 }
diff --git a/gnd/src/main/java/com/google/android/gnd/ui/editobservation/PhotoFieldViewModel.java b/gnd/src/main/java/com/google/android/gnd/ui/editobservation/PhotoFieldViewModel.java
index ce93bd9f4..1e6183afc 100644
--- a/gnd/src/main/java/com/google/android/gnd/ui/editobservation/PhotoFieldViewModel.java
+++ b/gnd/src/main/java/com/google/android/gnd/ui/editobservation/PhotoFieldViewModel.java
@@ -16,6 +16,8 @@
 
 package com.google.android.gnd.ui.editobservation;
 
+import static com.google.android.gnd.persistence.remote.firestore.FirestoreStorageManager.getRemoteMediaPath;
+
 import android.content.res.Resources;
 import android.net.Uri;
 import androidx.lifecycle.LiveData;
@@ -25,13 +27,23 @@ import com.google.android.gnd.model.form.Field;
 import com.google.android.gnd.model.observation.TextResponse;
 import com.google.android.gnd.repository.UserMediaRepository;
 import com.google.android.gnd.rx.annotations.Hot;
+import com.google.android.gnd.ui.editobservation.EditObservationViewModel.PhotoResult;
+import java.io.File;
+import java.io.IOException;
+import javax.annotation.Nullable;
 import javax.inject.Inject;
+import timber.log.Timber;
 
 public class PhotoFieldViewModel extends AbstractFieldViewModel {
 
+  private final UserMediaRepository userMediaRepository;
+
   private final LiveData<Uri> uri;
   private final LiveData<Boolean> photoPresent;
 
+  @Nullable private String projectId;
+  @Nullable private String observationId;
+
   @Hot(replays = true)
   private final MutableLiveData<Field> showDialogClicks = new MutableLiveData<>();
 
@@ -41,6 +53,7 @@ public class PhotoFieldViewModel extends AbstractFieldViewModel {
   @Inject
   PhotoFieldViewModel(UserMediaRepository userMediaRepository, Resources resources) {
     super(resources);
+    this.userMediaRepository = userMediaRepository;
     this.photoPresent =
         LiveDataReactiveStreams.fromPublisher(
             getDetailsTextFlowable().map(path -> !path.isEmpty()));
@@ -76,4 +89,39 @@ public class PhotoFieldViewModel extends AbstractFieldViewModel {
   public void updateResponse(String value) {
     setResponse(TextResponse.fromString(value));
   }
+
+  public void setProjectId(@Nullable String projectId) {
+    this.projectId = projectId;
+  }
+
+  public void setObservationId(@Nullable String observationId) {
+    this.observationId = observationId;
+  }
+
+  public void onPhotoResult(PhotoResult photoResult) throws IOException {
+    if (projectId == null || observationId == null) {
+      Timber.e("projectId or observationId not set");
+      return;
+    }
+    if (!photoResult.getFieldId().equals(getField().getId())) {
+      // Update belongs to another field.
+      return;
+    }
+    if (photoResult.getBitmap().isEmpty()) {
+      clearResponse();
+      Timber.v("Photo cleared");
+      return;
+    }
+    File imageFile =
+        userMediaRepository.savePhoto(photoResult.getBitmap().get(), photoResult.getFieldId());
+    String filename = imageFile.getName();
+    String path = imageFile.getAbsolutePath();
+
+    // Add image to gallery.
+    userMediaRepository.addImageToGallery(path, filename);
+
+    // Update response.
+    String remoteDestinationPath = getRemoteMediaPath(projectId, observationId, filename);
+    updateResponse(remoteDestinationPath);
+  }
 }
diff --git a/gnd/src/test/java/com/google/android/gnd/system/CameraManagerTest.java b/gnd/src/test/java/com/google/android/gnd/system/CameraManagerTest.java
deleted file mode 100644
index c77ce9371..000000000
--- a/gnd/src/test/java/com/google/android/gnd/system/CameraManagerTest.java
+++ /dev/null
@@ -1,68 +0,0 @@
-/*
- * Copyright 2021 Google LLC
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *     https://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.google.android.gnd.system;
-
-import android.Manifest.permission;
-import android.app.Activity;
-import com.google.android.gnd.BaseHiltTest;
-import com.google.android.gnd.rx.Nil;
-import com.google.android.gnd.system.PermissionsManager.PermissionDeniedException;
-import dagger.hilt.android.testing.HiltAndroidTest;
-import io.reactivex.observers.TestObserver;
-import java.io.File;
-import javax.inject.Inject;
-import org.junit.Test;
-import org.junit.runner.RunWith;
-import org.robolectric.RobolectricTestRunner;
-
-@HiltAndroidTest
-@RunWith(RobolectricTestRunner.class)
-public class CameraManagerTest extends BaseHiltTest {
-
-  private static final int REQUEST_CODE = CameraManager.CAPTURE_PHOTO_REQUEST_CODE;
-  private static final File TEST_FILE = new File("foo/dir");
-
-  @Inject ActivityStreams activityStreams;
-  @Inject CameraManager cameraManager;
-  @Inject TestPermissionUtil permissionUtil;
-
-  private void mockPermissions(boolean allow) {
-    String[] permissions = {permission.WRITE_EXTERNAL_STORAGE, permission.CAMERA};
-    permissionUtil.setPermission(permissions, allow);
-  }
-
-  @Test
-  public void testLaunchPhotoCapture_whenPermissionGranted() {
-    TestObserver<Nil> testObserver = cameraManager.capturePhoto(TEST_FILE).test();
-    mockPermissions(true);
-    testObserver.assertNoErrors();
-  }
-
-  @Test
-  public void testLaunchPhotoCapture_whenPermissionDenied() {
-    TestObserver<Nil> testObserver = cameraManager.capturePhoto(TEST_FILE).test();
-    mockPermissions(false);
-    testObserver.assertError(PermissionDeniedException.class);
-  }
-
-  @Test
-  public void testCapturePhotoResult_requestCancelled() {
-    TestObserver<Nil> subscriber = cameraManager.capturePhotoResult().test();
-    activityStreams.onActivityResult(REQUEST_CODE, Activity.RESULT_CANCELED, null);
-    subscriber.assertResult();
-  }
-}