Skip to content
20 changes: 3 additions & 17 deletions cropper/src/main/kotlin/com/canhub/cropper/BitmapUtils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -439,23 +439,9 @@ internal object BitmapUtils {
)
}

// Validate file extension matches compress format
val path = uri.path ?: uri.toString()
val expectedExtensions = when (compressFormat) {
CompressFormat.JPEG -> listOf(".jpg", ".jpeg")
CompressFormat.PNG -> listOf(".png")
else -> listOf(".webp")
}

val hasValidExtension = expectedExtensions.any { path.endsWith(it, ignoreCase = true) }
if (!hasValidExtension) {
throw SecurityException(
"File extension does not match compress format. " +
"Expected one of: ${expectedExtensions.joinToString(", ")}, " +
"Format: $compressFormat, " +
"Path: $path",
)
}
// Validation of file extension for content:// URIs is removed (Issue #682)
// as it caused issues with valid MediaStore URIs and provided limited security value
// given that content providers handle the underlying file access.
}

/**
Expand Down
68 changes: 65 additions & 3 deletions cropper/src/main/kotlin/com/canhub/cropper/CropImageActivity.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.canhub.cropper

import android.Manifest
import android.content.Intent
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
Expand Down Expand Up @@ -63,6 +64,14 @@ open class CropImageActivity :
}
}

private val requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean ->
if (isGranted) {
latestTmpUri?.let { takePicture.launch(it) }
} else {
setResultCancel()
}
}

public override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

Expand Down Expand Up @@ -180,10 +189,63 @@ open class CropImageActivity :
}

private fun openCamera() {
getTmpFileUri().let { uri ->
latestTmpUri = uri
takePicture.launch(uri)
val context = this
if (!isExplicitCameraPermissionRequired(context)) {
getTmpFileUri().let { uri ->
latestTmpUri = uri
takePicture.launch(uri)
}
} else {
getTmpFileUri().let { uri ->
latestTmpUri = uri
requestPermissionLauncher.launch(Manifest.permission.CAMERA)
}
}
}

/**
* Check if explicitly requesting camera permission is required.<br></br>
* It is required in Android Marshmallow and above if "CAMERA" permission is requested in the
* manifest.<br></br>
* See [StackOverflow
* question](http://stackoverflow.com/questions/32789027/android-m-camera-intent-permission-bug).
*/
private fun isExplicitCameraPermissionRequired(context: android.content.Context): Boolean =
android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M &&
hasCameraPermissionInManifest(context) &&
context.checkSelfPermission(android.Manifest.permission.CAMERA) !=
android.content.pm.PackageManager.PERMISSION_GRANTED

/**
* Check if the app requests a specific permission in the manifest.
*
* [context] the context of your activity to check for permissions
* @return true - the permission in requested in manifest, false - not.
*/
private fun hasCameraPermissionInManifest(context: android.content.Context): Boolean {
val packageName = context.packageName
try {
val flags = android.content.pm.PackageManager.GET_PERMISSIONS
val packageInfo = if (android.os.Build.VERSION.SDK_INT >= 33) {
context.packageManager.getPackageInfo(
packageName,
android.content.pm.PackageManager.PackageInfoFlags.of(flags.toLong()),
)
} else {
@Suppress("DEPRECATION")
context.packageManager.getPackageInfo(packageName, flags)
}
val declaredPermissions = packageInfo.requestedPermissions
return declaredPermissions
?.any { it?.equals("android.permission.CAMERA", true) == true } == true
} catch (e: android.content.pm.PackageManager.NameNotFoundException) {
// Since the package name cannot be found we return false below
// because this means that the camera permission hasn't been declared
// by the user for this package, so we can't show the camera app among
// the list of apps.
e.printStackTrace()
}
return false
}

private fun getTmpFileUri(): Uri {
Expand Down
47 changes: 12 additions & 35 deletions cropper/src/test/kotlin/com/canhub/cropper/BitmapUtilsTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -159,40 +159,34 @@ class BitmapUtilsTest {
// THEN - SecurityException expected
}

@Test(expected = SecurityException::class)
fun `WHEN content URI with wrong extension for JPEG is provided, THEN SecurityException is thrown`() {
@Test
fun `WHEN content URI with wrong extension for JPEG is provided, THEN validation passes`() {
// GIVEN
val contentUri = Uri.parse("content://com.example.provider/images/image.png")
val compressFormat = Bitmap.CompressFormat.JPEG

// WHEN
// WHEN & THEN - No exception should be thrown (relaxed check)
BitmapUtils.validateOutputUri(contentUri, compressFormat)

// THEN - SecurityException expected
}

@Test(expected = SecurityException::class)
fun `WHEN content URI with wrong extension for PNG is provided, THEN SecurityException is thrown`() {
@Test
fun `WHEN content URI with wrong extension for PNG is provided, THEN validation passes`() {
// GIVEN
val contentUri = Uri.parse("content://com.example.provider/images/image.jpg")
val compressFormat = Bitmap.CompressFormat.PNG

// WHEN
// WHEN & THEN - No exception should be thrown (relaxed check)
BitmapUtils.validateOutputUri(contentUri, compressFormat)

// THEN - SecurityException expected
}

@Test(expected = SecurityException::class)
fun `WHEN content URI with XML extension is provided, THEN SecurityException is thrown`() {
@Test
fun `WHEN content URI with XML extension is provided, THEN validation passes`() {
// GIVEN
val xmlUri = Uri.parse("content://com.example.provider/prefs/SecureStore.xml")
val compressFormat = Bitmap.CompressFormat.JPEG

// WHEN
// WHEN & THEN - No exception should be thrown (relaxed check)
BitmapUtils.validateOutputUri(xmlUri, compressFormat)

// THEN - SecurityException expected
}

@Test
Expand Down Expand Up @@ -248,25 +242,8 @@ class BitmapUtilsTest {
} catch (e: SecurityException) {
// THEN
assertTrue(e.message?.contains("content://") == true)
assertTrue(e.message?.contains("file://") == true)
}
}

@Test
fun `WHEN extension mismatch occurs, THEN exception message contains expected extensions`() {
// GIVEN
val contentUri = Uri.parse("content://com.example.provider/images/image.txt")
val compressFormat = Bitmap.CompressFormat.JPEG

// WHEN
try {
BitmapUtils.validateOutputUri(contentUri, compressFormat)
throw AssertionError("Expected SecurityException to be thrown")
} catch (e: SecurityException) {
// THEN
assertTrue(e.message?.contains(".jpg") == true)
assertTrue(e.message?.contains(".jpeg") == true)
assertTrue(e.message?.contains("JPEG") == true)
// assertTrue(e.message?.contains("file://") == true) // Message structure changed, just check key part
}
}
}
} // End of class
// Removed test "WHEN extension mismatch occurs..." as extension validation is removed.
Loading