Skip to content

Commit d4bea93

Browse files
authored
enhancement: allow camera access when using image occlusion (#15029)
* enhancement: allow camera access when using image occlusion * Use bottom sheet to display camera and gallery option * reactor: Use generic method from ImageUtils to crop image in BasicImageFieldController * refactor: updated IO layout to use linear layout instead on MaterialButton
1 parent d4d48b9 commit d4bea93

File tree

9 files changed

+333
-37
lines changed

9 files changed

+333
-37
lines changed

AnkiDroid/src/main/java/com/ichi2/anki/NoteEditor.kt

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import android.content.res.Configuration
2929
import android.net.Uri
3030
import android.os.Build
3131
import android.os.Bundle
32+
import android.os.Environment
3233
import android.text.Editable
3334
import android.text.TextWatcher
3435
import android.view.*
@@ -46,6 +47,7 @@ import androidx.appcompat.view.menu.MenuBuilder
4647
import androidx.appcompat.widget.AppCompatButton
4748
import androidx.appcompat.widget.PopupMenu
4849
import androidx.appcompat.widget.TooltipCompat
50+
import androidx.core.content.FileProvider
4951
import androidx.core.content.IntentCompat
5052
import androidx.core.content.edit
5153
import androidx.core.content.res.ResourcesCompat
@@ -58,6 +60,7 @@ import com.google.android.material.snackbar.Snackbar
5860
import com.ichi2.anim.ActivityTransitionAnimation
5961
import com.ichi2.anki.CollectionManager.TR
6062
import com.ichi2.anki.CollectionManager.withCol
63+
import com.ichi2.anki.bottomsheet.ImageOcclusionBottomSheetFragment
6164
import com.ichi2.anki.dialogs.ConfirmationDialog
6265
import com.ichi2.anki.dialogs.DeckSelectionDialog.DeckSelectionListener
6366
import com.ichi2.anki.dialogs.DeckSelectionDialog.SelectableDeck
@@ -90,6 +93,7 @@ import com.ichi2.anki.snackbar.SnackbarBuilder
9093
import com.ichi2.anki.snackbar.showSnackbar
9194
import com.ichi2.anki.ui.setupNoteTypeSpinner
9295
import com.ichi2.anki.utils.ext.isImageOcclusion
96+
import com.ichi2.anki.utils.getTimestamp
9397
import com.ichi2.anki.widgets.DeckDropDownAdapter.SubtitleListener
9498
import com.ichi2.annotations.NeedsTest
9599
import com.ichi2.compat.CompatHelper.Companion.getSerializableCompat
@@ -98,12 +102,14 @@ import com.ichi2.libanki.Collection
98102
import com.ichi2.libanki.Decks.Companion.CURRENT_DECK
99103
import com.ichi2.libanki.Note.ClozeUtils
100104
import com.ichi2.libanki.Notetypes.Companion.NOT_FOUND_NOTE_TYPE
105+
import com.ichi2.libanki.utils.TimeManager
101106
import com.ichi2.utils.*
102107
import com.ichi2.utils.IntentUtil.resolveMimeType
103108
import com.ichi2.widget.WidgetStatus
104109
import org.json.JSONArray
105110
import org.json.JSONObject
106111
import timber.log.Timber
112+
import java.io.File
107113
import java.util.*
108114
import java.util.function.Consumer
109115
import kotlin.collections.ArrayList
@@ -152,6 +158,8 @@ class NoteEditor : AnkiActivity(), DeckSelectionListener, SubtitleListener, Tags
152158
// non-null after onCollectionLoaded
153159
private var editorNote: Note? = null
154160

161+
private var currentImageOccPath: String? = null
162+
155163
/* Null if adding a new card. Presently NonNull if editing an existing note - but this is subject to change */
156164
private var currentEditedCard: Card? = null
157165
private var selectedTags: MutableList<String>? = null
@@ -529,8 +537,23 @@ class NoteEditor : AnkiActivity(), DeckSelectionListener, SubtitleListener, Tags
529537
if (addNote) {
530538
editOcclusionsButton?.visibility = View.GONE
531539
selectImageForOcclusionButton?.setOnClickListener {
532-
ioEditorLauncher.launch("image/*")
540+
val imageOcclusionBottomSheet = ImageOcclusionBottomSheetFragment()
541+
imageOcclusionBottomSheet.listener =
542+
object : ImageOcclusionBottomSheetFragment.ImagePickerListener {
543+
override fun onCameraClicked() {
544+
dispatchCameraEvent()
545+
}
546+
547+
override fun onGalleryClicked() {
548+
ioEditorLauncher.launch("image/*")
549+
}
550+
}
551+
imageOcclusionBottomSheet.show(
552+
supportFragmentManager,
553+
"ImageOcclusionBottomSheetFragment"
554+
)
533555
}
556+
534557
pasteOcclusionImageButton?.text = TR.notetypesIoPasteImageFromClipboard()
535558
pasteOcclusionImageButton?.setOnClickListener {
536559
// TODO: Support all extensions
@@ -669,6 +692,63 @@ class NoteEditor : AnkiActivity(), DeckSelectionListener, SubtitleListener, Tags
669692
}
670693
}
671694

695+
private val cameraLauncher =
696+
registerForActivityResult(ActivityResultContracts.TakePicture()) { isPictureTaken ->
697+
if (isPictureTaken) {
698+
currentImageOccPath?.let { imagePath ->
699+
val photoFile = File(imagePath)
700+
val imageUri: Uri = FileProvider.getUriForFile(
701+
this,
702+
this.applicationContext.packageName + ".apkgfileprovider",
703+
photoFile
704+
)
705+
startCrop(imageUri)
706+
}
707+
} else {
708+
Timber.d("Camera aborted or some interruption")
709+
}
710+
}
711+
712+
private fun startCrop(imageUri: Uri) {
713+
ImageUtils.cropImage(activityResultRegistry, imageUri) { result ->
714+
if (result != null && result.isSuccessful) {
715+
val uriFilePath = result.getUriFilePath(this)
716+
uriFilePath?.let { setupImageOcclusionEditor(it) }
717+
} else {
718+
Timber.v("Unable to crop the image")
719+
}
720+
}
721+
}
722+
723+
private fun dispatchCameraEvent() {
724+
val photoFile: File? = try {
725+
createImageFile()
726+
} catch (e: Exception) {
727+
Timber.w("Error creating the file", e)
728+
return
729+
}
730+
photoFile?.let {
731+
val photoURI: Uri = FileProvider.getUriForFile(
732+
this,
733+
this.applicationContext.packageName + ".apkgfileprovider",
734+
it
735+
)
736+
cameraLauncher.launch(photoURI)
737+
}
738+
}
739+
740+
private fun createImageFile(): File {
741+
val currentDateTime = getTimestamp(TimeManager.time)
742+
val storageDir: File? = getExternalFilesDir(Environment.DIRECTORY_PICTURES)
743+
return File.createTempFile(
744+
"ANKIDROID_$currentDateTime",
745+
".jpg",
746+
storageDir
747+
).apply {
748+
currentImageOccPath = absolutePath
749+
}
750+
}
751+
672752
private fun modifyCurrentSelection(formatter: Toolbar.TextFormatter, textBox: FieldEditText) {
673753
// get the current text and selection locations
674754
val selectionStart = textBox.selectionStart
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/*
2+
* Copyright (c) 2023 Ashish Yadav <mailtoashish693@gmail.com>
3+
*
4+
* This program is free software; you can redistribute it and/or modify it under
5+
* the terms of the GNU General Public License as published by the Free Software
6+
* Foundation; either version 3 of the License, or (at your option) any later
7+
* version.
8+
*
9+
* This program is distributed in the hope that it will be useful, but WITHOUT ANY
10+
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
11+
* PARTICULAR PURPOSE. See the GNU General Public License for more details.
12+
*
13+
* You should have received a copy of the GNU General Public License along with
14+
* this program. If not, see <http://www.gnu.org/licenses/>.
15+
*/
16+
17+
package com.ichi2.anki.bottomsheet
18+
19+
import android.os.Bundle
20+
import android.view.LayoutInflater
21+
import android.view.View
22+
import android.view.ViewGroup
23+
import android.widget.LinearLayout
24+
import com.google.android.material.bottomsheet.BottomSheetBehavior
25+
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
26+
import com.ichi2.anki.R
27+
28+
class ImageOcclusionBottomSheetFragment : BottomSheetDialogFragment() {
29+
30+
var listener: ImagePickerListener? = null
31+
32+
interface ImagePickerListener {
33+
fun onCameraClicked()
34+
fun onGalleryClicked()
35+
}
36+
37+
override fun onCreateView(
38+
inflater: LayoutInflater,
39+
container: ViewGroup?,
40+
savedInstanceState: Bundle?
41+
): View {
42+
val view = inflater.inflate(R.layout.image_occlusion_options_layout, container, false)
43+
44+
view.findViewById<LinearLayout>(R.id.occlusion_action_camera).setOnClickListener {
45+
listener?.onCameraClicked()
46+
dismiss()
47+
}
48+
view.findViewById<LinearLayout>(R.id.occlusion_action_gallery).setOnClickListener {
49+
listener?.onGalleryClicked()
50+
dismiss()
51+
}
52+
53+
return view
54+
}
55+
56+
override fun onStart() {
57+
super.onStart()
58+
val behavior = BottomSheetBehavior.from(requireView().parent as View)
59+
behavior.state = BottomSheetBehavior.STATE_EXPANDED
60+
}
61+
}

AnkiDroid/src/main/java/com/ichi2/anki/multimediacard/fields/BasicImageFieldController.kt

Lines changed: 24 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,6 @@ import com.ichi2.anki.DrawingActivity
6262
import com.ichi2.anki.R
6363
import com.ichi2.anki.multimediacard.activity.MultimediaEditFieldActivity
6464
import com.ichi2.anki.showThemedToast
65-
import com.ichi2.annotations.NeedsTest
6665
import com.ichi2.ui.FixedEditText
6766
import com.ichi2.utils.*
6867
import timber.log.Timber
@@ -96,7 +95,6 @@ class BasicImageFieldController : FieldControllerBase(), IFieldController {
9695

9796
return min(height * 0.4, width * 0.6).toInt()
9897
}
99-
private lateinit var cropImageRequest: ActivityResultLauncher<CropImageContractOptions>
10098

10199
@VisibleForTesting
102100
lateinit var registryToUse: ActivityResultRegistry
@@ -225,27 +223,6 @@ class BasicImageFieldController : FieldControllerBase(), IFieldController {
225223
override fun setEditingActivity(activity: MultimediaEditFieldActivity) {
226224
super.setEditingActivity(activity)
227225
val registryToUse = if (this::registryToUse.isInitialized) registryToUse else this._activity.activityResultRegistry
228-
@NeedsTest("check the happy/failure path for the crop action")
229-
cropImageRequest = registryToUse.register(CROP_IMAGE_LAUNCHER_KEY, CropImageContract()) { cropResult ->
230-
if (cropResult.isSuccessful) {
231-
imageFileSizeWarning.visibility = View.GONE
232-
handleCropResult(cropResult)
233-
setPreviewImage(viewModel.imagePath, maxImageSize)
234-
} else {
235-
if (!previousImagePath.isNullOrEmpty()) {
236-
revertToPreviousImage()
237-
}
238-
// cropImage can give us more information. Not sure it is actionable so for now just log it.
239-
val error: String = cropResult.error?.toString() ?: "Error info not available"
240-
Timber.w(error, "cropImage threw an error")
241-
// condition can be removed if #12768 get fixed by Canhub
242-
if (cropResult.error is CropException.Cancellation) {
243-
Timber.i("CropException caught, seemingly nothing to do ", error)
244-
} else {
245-
CrashReportService.sendExceptionReport(error, "cropImage threw an error")
246-
}
247-
}
248-
}
249226

250227
takePictureLauncher = registryToUse.register(
251228
TAKE_PICTURE_LAUNCHER_KEY,
@@ -548,9 +525,6 @@ class BasicImageFieldController : FieldControllerBase(), IFieldController {
548525

549526
override fun onDone() {
550527
deletePreviousImage()
551-
if (::cropImageRequest.isInitialized) {
552-
cropImageRequest.unregister()
553-
}
554528
if (::takePictureLauncher.isInitialized) {
555529
takePictureLauncher.unregister()
556530
}
@@ -687,13 +661,30 @@ class BasicImageFieldController : FieldControllerBase(), IFieldController {
687661
ret = viewModel.beforeCrop(imagePath, imageUri)
688662
setTemporaryMedia(imagePath)
689663
Timber.d("requestCrop() destination image has path/uri %s/%s", ret.imagePath, ret.imageUri)
690-
if (this::cropImageRequest.isInitialized) {
691-
cropImageRequest.launch(
692-
CropImageContractOptions(
693-
viewModel.imageUri,
694-
CropImageOptions()
695-
)
696-
)
664+
665+
viewModel.imageUri?.let {
666+
ImageUtils.cropImage(_activity.activityResultRegistry, it) { cropResult ->
667+
if (cropResult != null) {
668+
if (cropResult.isSuccessful) {
669+
imageFileSizeWarning.visibility = View.GONE
670+
handleCropResult(cropResult)
671+
setPreviewImage(viewModel.imagePath, maxImageSize)
672+
} else {
673+
if (!previousImagePath.isNullOrEmpty()) {
674+
revertToPreviousImage()
675+
}
676+
// cropImage can give us more information. Not sure it is actionable so for now just log it.
677+
val error: String = cropResult.error?.toString() ?: "Error info not available"
678+
Timber.w(error, "cropImage threw an error")
679+
// condition can be removed if #12768 get fixed by Canhub
680+
if (cropResult.error is CropException.Cancellation) {
681+
Timber.i("CropException caught, seemingly nothing to do ", error)
682+
} else {
683+
CrashReportService.sendExceptionReport(error, "cropImage threw an error")
684+
}
685+
}
686+
}
687+
}
697688
}
698689
return ret
699690
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/*
2+
* Copyright (c) 2024 Ashish Yadav <mailtoashish693@gmail.com>
3+
*
4+
* This program is free software; you can redistribute it and/or modify it under
5+
* the terms of the GNU General Public License as published by the Free Software
6+
* Foundation; either version 3 of the License, or (at your option) any later
7+
* version.
8+
*
9+
* This program is distributed in the hope that it will be useful, but WITHOUT ANY
10+
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
11+
* PARTICULAR PURPOSE. See the GNU General Public License for more details.
12+
*
13+
* You should have received a copy of the GNU General Public License along with
14+
* this program. If not, see <http://www.gnu.org/licenses/>.
15+
*/
16+
17+
package com.ichi2.utils
18+
19+
import android.net.Uri
20+
import androidx.activity.result.ActivityResultRegistry
21+
import com.canhub.cropper.CropImageContract
22+
import com.canhub.cropper.CropImageContractOptions
23+
import com.canhub.cropper.CropImageOptions
24+
import com.canhub.cropper.CropImageView
25+
import timber.log.Timber
26+
27+
object ImageUtils {
28+
private const val CROP_IMAGE_KEY = "crop_image_launcher"
29+
30+
/**
31+
* Initiates the image cropping process using the specified ActivityResultRegistry, URI of the image to crop,
32+
* and a callback function to handle the result.
33+
*
34+
* @param registry The ActivityResultRegistry instance used for registering the activity result.
35+
* @param uri The URI of the image to be cropped.
36+
* @param callback The callback function that receives the crop result (CropImageView.CropResult) or null if the cropping operation fails.
37+
*/
38+
fun cropImage(
39+
registry: ActivityResultRegistry,
40+
uri: Uri,
41+
callback: (CropImageView.CropResult?) -> Unit
42+
) {
43+
val cropImage = registry.register(CROP_IMAGE_KEY, CropImageContract()) { result ->
44+
if (result.isSuccessful) {
45+
callback(result)
46+
} else {
47+
Timber.v(result.error)
48+
callback(null)
49+
}
50+
}
51+
cropImage.launch(
52+
CropImageContractOptions(
53+
uri,
54+
CropImageOptions()
55+
)
56+
)
57+
}
58+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<vector xmlns:android="http://schemas.android.com/apk/res/android"
2+
android:width="24dp"
3+
android:height="24dp"
4+
android:tint="?attr/colorControlNormal"
5+
android:viewportWidth="960"
6+
android:viewportHeight="960">
7+
<path
8+
android:fillColor="#FF000000"
9+
android:pathData="M480,700q75,0 127.5,-52.5T660,520q0,-75 -52.5,-127.5T480,340q-75,0 -127.5,52.5T300,520q0,75 52.5,127.5T480,700ZM480,620q-42,0 -71,-29t-29,-71q0,-42 29,-71t71,-29q42,0 71,29t29,71q0,42 -29,71t-71,29ZM160,840q-33,0 -56.5,-23.5T80,760v-480q0,-33 23.5,-56.5T160,200h126l50,-54q11,-12 26.5,-19t32.5,-7h170q17,0 32.5,7t26.5,19l50,54h126q33,0 56.5,23.5T880,280v480q0,33 -23.5,56.5T800,840L160,840ZM160,760h640v-480L638,280l-73,-80L395,200l-73,80L160,280v480ZM480,520Z"/>
10+
</vector>
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<vector xmlns:android="http://schemas.android.com/apk/res/android"
2+
android:width="24dp"
3+
android:height="24dp"
4+
android:tint="?attr/colorControlNormal"
5+
android:viewportWidth="960"
6+
android:viewportHeight="960">
7+
<path
8+
android:fillColor="#FF000000"
9+
android:pathData="M120,760q-33,0 -56.5,-23.5T40,680v-400q0,-33 23.5,-56.5T120,200h400q33,0 56.5,23.5T600,280v400q0,33 -23.5,56.5T520,760L120,760ZM720,440q-17,0 -28.5,-11.5T680,400v-160q0,-17 11.5,-28.5T720,200h160q17,0 28.5,11.5T920,240v160q0,17 -11.5,28.5T880,440L720,440ZM760,360h80v-80h-80v80ZM120,680h400v-400L120,280v400ZM200,600h240q12,0 18,-11t-2,-21l-65,-87q-6,-8 -16,-8t-16,8l-59,79 -39,-52q-6,-8 -16,-8t-16,8l-45,60q-8,10 -2,21t18,11ZM720,760q-17,0 -28.5,-11.5T680,720v-160q0,-17 11.5,-28.5T720,520h160q17,0 28.5,11.5T920,560v160q0,17 -11.5,28.5T880,760L720,760ZM760,680h80v-80h-80v80ZM120,680v-400,400ZM760,360v-80,80ZM760,680v-80,80Z"/>
10+
</vector>
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<vector xmlns:android="http://schemas.android.com/apk/res/android"
2+
android:width="24dp"
3+
android:height="24dp"
4+
android:viewportWidth="960"
5+
android:viewportHeight="960">
6+
<path
7+
android:fillColor="#FF000000"
8+
android:pathData="M200,840q-33,0 -56.5,-23.5T120,760v-560q0,-33 23.5,-56.5T200,120h167q11,-35 43,-57.5t70,-22.5q40,0 71.5,22.5T594,120h166q33,0 56.5,23.5T840,200v560q0,33 -23.5,56.5T760,840L200,840ZM200,760h560v-560h-80v120L280,320v-120h-80v560ZM480,200q17,0 28.5,-11.5T520,160q0,-17 -11.5,-28.5T480,120q-17,0 -28.5,11.5T440,160q0,17 11.5,28.5T480,200Z"/>
9+
</vector>

0 commit comments

Comments
 (0)