-
Notifications
You must be signed in to change notification settings - Fork 1.4k
Expand file tree
/
Copy pathFilePicker.kt
More file actions
508 lines (463 loc) · 18.4 KB
/
FilePicker.kt
File metadata and controls
508 lines (463 loc) · 18.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
package fr.free.nrw.commons.filepicker
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.provider.MediaStore
import androidx.activity.result.ActivityResult
import androidx.activity.result.ActivityResultLauncher
import androidx.preference.PreferenceManager
import fr.free.nrw.commons.customselector.model.Image
import fr.free.nrw.commons.customselector.ui.selector.CustomSelectorActivity
import fr.free.nrw.commons.filepicker.PickedFiles.singleFileList
import java.io.File
import java.io.IOException
import java.net.URISyntaxException
import android.graphics.Bitmap
import android.graphics.ImageDecoder
import android.os.Build
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import java.io.FileOutputStream
object FilePicker : Constants {
private const val KEY_PHOTO_URI = "photo_uri"
private const val KEY_VIDEO_URI = "video_uri"
private const val KEY_LAST_CAMERA_PHOTO = "last_photo"
private const val KEY_LAST_CAMERA_VIDEO = "last_video"
private const val KEY_TYPE = "type"
// Add extra for single selection
private const val EXTRA_SINGLE_SELECTION = "EXTRA_SINGLE_SELECTION"
/**
* Returns the uri of the clicked image so that it can be put in MediaStore
*/
@Throws(IOException::class)
@JvmStatic
private fun createCameraPictureFile(context: Context): Uri {
val imagePath = PickedFiles.getCameraPicturesLocation(context)
val uri = PickedFiles.getUriToFile(context, imagePath)
val editor = PreferenceManager.getDefaultSharedPreferences(context).edit()
editor.putString(KEY_PHOTO_URI, uri.toString())
editor.putString(KEY_LAST_CAMERA_PHOTO, imagePath.toString())
editor.apply()
return uri
}
@JvmStatic
private fun createGalleryIntent(
context: Context,
type: Int,
openDocumentIntentPreferred: Boolean
): Intent {
// storing picked image type to shared preferences
storeType(context, type)
// Supported types are SVG, PNG and JPEG, GIF, TIFF, WebP, XCF and HEIC (for future conversion)
val mimeTypes = arrayOf(
"image/jpg",
"image/png",
"image/jpeg",
"image/gif",
"image/tiff",
"image/webp",
"image/xcf",
"image/svg+xml",
"image/heic",
"image/heif"
)
return plainGalleryPickerIntent(openDocumentIntentPreferred)
.putExtra(
Intent.EXTRA_ALLOW_MULTIPLE,
configuration(context).allowsMultiplePickingInGallery()
)
.putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes)
}
/**
* CreateCustomSectorIntent, creates intent for custom selector activity.
* @param context
* @param type
* @param singleSelection If true, restricts to single image selection
* @return Custom selector intent
*/
@JvmStatic
private fun createCustomSelectorIntent(context: Context, type: Int, singleSelection: Boolean = false): Intent {
storeType(context, type)
val intent = Intent(context, CustomSelectorActivity::class.java)
if (singleSelection) {
intent.putExtra(EXTRA_SINGLE_SELECTION, true)
}
return intent
}
@JvmStatic
private fun createCameraForImageIntent(context: Context, type: Int): Intent {
storeType(context, type)
val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
try {
val capturedImageUri = createCameraPictureFile(context)
// We have to explicitly grant the write permission since Intent.setFlag works only on API Level >=20
grantWritePermission(context, intent, capturedImageUri)
intent.putExtra(MediaStore.EXTRA_OUTPUT, capturedImageUri)
} catch (e: Exception) {
e.printStackTrace()
}
return intent
}
@JvmStatic
private fun revokeWritePermission(context: Context, uri: Uri) {
context.revokeUriPermission(
uri,
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION
)
}
@JvmStatic
private fun grantWritePermission(context: Context, intent: Intent, uri: Uri) {
val resInfoList =
context.packageManager.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY)
for (resolveInfo in resInfoList) {
val packageName = resolveInfo.activityInfo.packageName
context.grantUriPermission(
packageName,
uri,
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION
)
}
}
@JvmStatic
private fun storeType(context: Context, type: Int) {
PreferenceManager.getDefaultSharedPreferences(context).edit().putInt(KEY_TYPE, type).apply()
}
@JvmStatic
private fun restoreType(context: Context): Int {
return PreferenceManager.getDefaultSharedPreferences(context).getInt(KEY_TYPE, 0)
}
/**
* Opens default gallery or available galleries picker if there is no default
*
* @param type Custom type of your choice, which will be returned with the images
*/
@JvmStatic
fun openGallery(
activity: Activity,
resultLauncher: ActivityResultLauncher<Intent>,
type: Int,
openDocumentIntentPreferred: Boolean
) {
val intent = createGalleryIntent(activity, type, openDocumentIntentPreferred)
resultLauncher.launch(intent)
}
/**
* Opens Custom Selector
*/
@JvmStatic
fun openCustomSelector(
activity: Activity,
resultLauncher: ActivityResultLauncher<Intent>,
type: Int,
singleSelection: Boolean = false
) {
val intent = createCustomSelectorIntent(activity, type, singleSelection)
resultLauncher.launch(intent)
}
/**
* Opens the camera app to pick image clicked by user
*/
@JvmStatic
fun openCameraForImage(
activity: Activity,
resultLauncher: ActivityResultLauncher<Intent>,
type: Int
) {
val intent = createCameraForImageIntent(activity, type)
resultLauncher.launch(intent)
}
@Throws(URISyntaxException::class)
@JvmStatic
private fun takenCameraPicture(context: Context): UploadableFile? {
val lastCameraPhoto = PreferenceManager.getDefaultSharedPreferences(context)
.getString(KEY_LAST_CAMERA_PHOTO, null)
return if (lastCameraPhoto != null) {
UploadableFile(File(lastCameraPhoto))
} else {
null
}
}
@Throws(URISyntaxException::class)
@JvmStatic
private fun takenCameraVideo(context: Context): UploadableFile? {
val lastCameraVideo = PreferenceManager.getDefaultSharedPreferences(context)
.getString(KEY_LAST_CAMERA_VIDEO, null)
return if (lastCameraVideo != null) {
UploadableFile(File(lastCameraVideo))
} else {
null
}
}
@JvmStatic
fun handleExternalImagesPicked(data: Intent?, activity: Activity): List<UploadableFile> {
return try {
getFilesFromGalleryPictures(data, activity)
} catch (e: IOException) {
e.printStackTrace()
emptyList()
} catch (e: SecurityException) {
e.printStackTrace()
emptyList()
}
}
@JvmStatic
private fun isPhoto(data: Intent?): Boolean {
return data == null || (data.data == null && data.clipData == null)
}
@JvmStatic
private fun plainGalleryPickerIntent(
openDocumentIntentPreferred: Boolean
): Intent {
/*
* Asking for ACCESS_MEDIA_LOCATION at runtime solved the location-loss issue
* in the custom selector in Contributions fragment.
* Detailed discussion: https://github.com/commons-app/apps-android-commons/issues/5015
*
* This permission check, however, was insufficient to fix location-loss in
* the regular selector in Contributions fragment and Nearby fragment,
* especially on some devices running Android 13 that use the new Photo Picker by default.
*
* New Photo Picker: https://developer.android.com/training/data-storage/shared/photopicker
*
* The new Photo Picker introduced by Android redacts location tags from EXIF metadata.
* Reported on the Google Issue Tracker: https://issuetracker.google.com/issues/243294058
* Status: Won't fix (Intended behaviour)
*
* Switched intent from ACTION_GET_CONTENT to ACTION_OPEN_DOCUMENT (by default; can
* be changed through the Setting page) as:
*
* ACTION_GET_CONTENT opens the 'best application' for choosing that kind of data
* The best application is the new Photo Picker that redacts the location tags
*
* ACTION_OPEN_DOCUMENT, however, displays the various DocumentsProvider instances
* installed on the device, letting the user interactively navigate through them.
*
* So, this allows us to use the traditional file picker that does not redact location tags
* from EXIF.
*
*/
val intent = if (openDocumentIntentPreferred) {
Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
}
} else {
Intent(Intent.ACTION_GET_CONTENT)
}
intent.type = "image/*"
return intent
}
@JvmStatic
fun onPictureReturnedFromDocuments(
result: ActivityResult,
activity: Activity,
callbacks: Callbacks
) {
if (result.resultCode == Activity.RESULT_OK && !isPhoto(result.data)) {
takePersistableUriPermissions(activity, result)
try {
val files = getFilesFromGalleryPictures(result.data, activity)
callbacks.onImagesPicked(files, ImageSource.DOCUMENTS, restoreType(activity))
} catch (e: Exception) {
e.printStackTrace()
callbacks.onImagePickerError(e, ImageSource.DOCUMENTS, restoreType(activity))
}
} else {
callbacks.onCanceled(ImageSource.DOCUMENTS, restoreType(activity))
}
}
/**
* takePersistableUriPermission is necessary to persist the URI permission as
* the permission granted by the system for read or write access on ACTION_OPEN_DOCUMENT
* lasts only until the user's device restarts.
* Ref: https://developer.android.com/training/data-storage/shared/documents-files#persist-permissions
*
* This helps fix the SecurityException reported in this issue:
* https://github.com/commons-app/apps-android-commons/issues/6357
*/
private fun takePersistableUriPermissions(context: Context, result: ActivityResult) {
result.data?.let { intentData ->
val takeFlags: Int = (Intent.FLAG_GRANT_READ_URI_PERMISSION)
// Persist the URI permission for all URIs in the clip data
// if multiple images are selected,
// or for the single URI if only one image is selected
intentData.clipData?.let { clipData ->
for (i in 0 until clipData.itemCount) {
context.contentResolver.takePersistableUriPermission(
clipData.getItemAt(i).uri, takeFlags)
}
} ?: intentData.data?.let { uri ->
context.contentResolver.takePersistableUriPermission(uri, takeFlags)
}
}
}
/**
* onPictureReturnedFromCustomSelector.
* Retrieve and forward the images to upload wizard through callback.
*/
@JvmStatic
fun onPictureReturnedFromCustomSelector(
result: ActivityResult,
activity: Activity,
callbacks: Callbacks
) {
if (result.resultCode == Activity.RESULT_OK) {
try {
val files = getFilesFromCustomSelector(result.data, activity)
callbacks.onImagesPicked(files, ImageSource.CUSTOM_SELECTOR, restoreType(activity))
} catch (e: Exception) {
e.printStackTrace()
callbacks.onImagePickerError(e, ImageSource.CUSTOM_SELECTOR, restoreType(activity))
}
} else {
callbacks.onCanceled(ImageSource.CUSTOM_SELECTOR, restoreType(activity))
}
}
/**
* Get files from custom selector
* Retrieve and process the selected images from the custom selector.
*/
@Throws(IOException::class, SecurityException::class)
@JvmStatic
private fun getFilesFromCustomSelector(
data: Intent?,
activity: Activity
): List<UploadableFile> {
val files = mutableListOf<UploadableFile>()
val images = data?.getParcelableArrayListExtra<Image>("Images")
images?.forEach { image ->
val uri = image.uri
var file = PickedFiles.pickedExistingPicture(activity, uri)
file = handleHeicFile(file, activity)
files.add(file)
}
if (configuration(activity).shouldCopyPickedImagesToPublicGalleryAppFolder()) {
PickedFiles.copyFilesInSeparateThread(activity, files)
}
return files
}
@JvmStatic
fun onPictureReturnedFromGallery(
result: ActivityResult,
activity: Activity,
callbacks: Callbacks
) {
if (result.resultCode == Activity.RESULT_OK && !isPhoto(result.data)) {
takePersistableUriPermissions(activity, result)
try {
val files = getFilesFromGalleryPictures(result.data, activity)
callbacks.onImagesPicked(files, ImageSource.GALLERY, restoreType(activity))
} catch (e: Exception) {
e.printStackTrace()
callbacks.onImagePickerError(e, ImageSource.GALLERY, restoreType(activity))
}
} else {
callbacks.onCanceled(ImageSource.GALLERY, restoreType(activity))
}
}
@JvmStatic
fun convertHeicToJpg(file: File, context: Context): File {
val bitmap = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
val source = ImageDecoder.createSource(context.contentResolver, Uri.fromFile(file))
ImageDecoder.decodeBitmap(source)
} else {
throw IllegalStateException("HEIC conversion requires Android P+")
}
val jpgFile = File(file.parent, file.nameWithoutExtension + ".jpg")
FileOutputStream(jpgFile).use { out ->
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, out)
}
return jpgFile
}
@JvmStatic
private fun handleHeicFile(file: UploadableFile, activity: Activity): UploadableFile {
val extension = file.file.extension.lowercase()
return if (extension == "heic" || extension == "heif") {
val jpgFile = convertHeicToJpg(file.file, activity)
val uploadableFile = UploadableFile(jpgFile)
uploadableFile.hasUnsupportedFormat = true
uploadableFile
} else {
file
}
}
@Throws(IOException::class, SecurityException::class)
@JvmStatic
private fun getFilesFromGalleryPictures(
data: Intent?,
activity: Activity
): List<UploadableFile> {
val files = mutableListOf<UploadableFile>()
val clipData = data?.clipData
if (clipData == null) {
val uri = data?.data
var file = PickedFiles.pickedExistingPicture(activity, uri!!)
file = handleHeicFile(file, activity)
files.add(file)
} else {
for (i in 0 until clipData.itemCount) {
val uri = clipData.getItemAt(i).uri
var file = PickedFiles.pickedExistingPicture(activity, uri)
file = handleHeicFile(file, activity)
files.add(file)
}
}
if (configuration(activity).shouldCopyPickedImagesToPublicGalleryAppFolder()) {
PickedFiles.copyFilesInSeparateThread(activity, files)
}
return files
}
@JvmStatic
fun onPictureReturnedFromCamera(
activityResult: ActivityResult,
activity: Activity,
callbacks: Callbacks
) {
if (activityResult.resultCode == Activity.RESULT_OK) {
try {
val lastImageUri = PreferenceManager.getDefaultSharedPreferences(activity)
.getString(KEY_PHOTO_URI, null)
if (!lastImageUri.isNullOrEmpty()) {
revokeWritePermission(activity, Uri.parse(lastImageUri))
}
val photoFile = takenCameraPicture(activity)
val files = mutableListOf<UploadableFile>()
photoFile?.let { files.add(it) }
if (photoFile == null) {
val e = IllegalStateException("Unable to get the picture returned from camera")
callbacks.onImagePickerError(e, ImageSource.CAMERA_IMAGE, restoreType(activity))
} else {
if (configuration(activity).shouldCopyTakenPhotosToPublicGalleryAppFolder()) {
PickedFiles.copyFilesInSeparateThread(activity, singleFileList(photoFile))
}
callbacks.onImagesPicked(files, ImageSource.CAMERA_IMAGE, restoreType(activity))
}
PreferenceManager.getDefaultSharedPreferences(activity).edit()
.remove(KEY_LAST_CAMERA_PHOTO)
.remove(KEY_PHOTO_URI)
.apply()
} catch (e: Exception) {
e.printStackTrace()
callbacks.onImagePickerError(e, ImageSource.CAMERA_IMAGE, restoreType(activity))
}
} else {
callbacks.onCanceled(ImageSource.CAMERA_IMAGE, restoreType(activity))
}
}
@JvmStatic
fun configuration(context: Context): FilePickerConfiguration {
return FilePickerConfiguration(context)
}
enum class ImageSource {
GALLERY, DOCUMENTS, CAMERA_IMAGE, CAMERA_VIDEO, CUSTOM_SELECTOR
}
interface Callbacks {
fun onImagePickerError(e: Exception, source: ImageSource, type: Int)
fun onImagesPicked(imageFiles: List<UploadableFile>, source: ImageSource, type: Int)
fun onCanceled(source: ImageSource, type: Int)
}
fun interface HandleActivityResult {
fun onHandleActivityResult(callbacks: Callbacks)
}
}