Skip to content

Commit fbab289

Browse files
committed
Migrate off Cropper's built-in CropImageActivity
1 parent 6497103 commit fbab289

File tree

10 files changed

+413
-123
lines changed

10 files changed

+413
-123
lines changed

app/src/main/AndroidManifest.xml

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -62,12 +62,9 @@
6262
android:configChanges="orientation|screenSize"
6363
android:exported="false"
6464
android:label="@string/edit_theme" />
65-
<!-- don't export CropImageActivity -->
6665
<activity
67-
android:name="com.canhub.cropper.CropImageActivity"
68-
android:exported="false"
69-
android:theme="@style/Theme.CropActivityTheme"
70-
tools:replace="android:exported" />
66+
android:name=".ui.main.CropImageActivity"
67+
android:exported="false" />
7168
<activity
7269
android:name=".ui.main.MainActivity"
7370
android:exported="true"

app/src/main/java/org/fcitx/fcitx5/android/data/theme/Theme.kt

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@ import android.graphics.drawable.Drawable
1212
import android.os.Parcelable
1313
import kotlinx.parcelize.Parcelize
1414
import kotlinx.serialization.Serializable
15+
import org.fcitx.fcitx5.android.utils.DarkenColorFilter
1516
import org.fcitx.fcitx5.android.utils.RectSerializer
1617
import org.fcitx.fcitx5.android.utils.appContext
17-
import org.fcitx.fcitx5.android.utils.DarkenColorFilter
1818
import java.io.File
1919

2020
@Serializable
@@ -88,6 +88,7 @@ sealed class Theme : Parcelable {
8888
val srcFilePath: String,
8989
val brightness: Int = 70,
9090
val cropRect: @Serializable(RectSerializer::class) Rect?,
91+
val cropRotation: Int = 0
9192
) : Parcelable {
9293
fun toDrawable(): Drawable? {
9394
val cropped = File(croppedFilePath)
@@ -205,14 +206,16 @@ sealed class Theme : Parcelable {
205206
originBackgroundImage: String,
206207
brightness: Int = 70,
207208
cropBackgroundRect: Rect? = null,
209+
cropBackgroundRotation: Int = 0
208210
) = Custom(
209211
name,
210212
isDark,
211213
Custom.CustomBackground(
212214
croppedBackgroundImage,
213215
originBackgroundImage,
214216
brightness,
215-
cropBackgroundRect
217+
cropBackgroundRect,
218+
cropBackgroundRotation
216219
),
217220
backgroundColor,
218221
barColor,
Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
/*
2+
* SPDX-License-Identifier: LGPL-2.1-or-later
3+
* SPDX-FileCopyrightText: Copyright 2024 Fcitx5 for Android Contributors
4+
*/
5+
6+
package org.fcitx.fcitx5.android.ui.main
7+
8+
import android.content.Context
9+
import android.content.Intent
10+
import android.graphics.Bitmap
11+
import android.graphics.BitmapFactory
12+
import android.graphics.Rect
13+
import android.net.Uri
14+
import android.os.Bundle
15+
import android.os.Parcelable
16+
import android.view.Menu
17+
import android.view.ViewGroup
18+
import androidx.activity.addCallback
19+
import androidx.activity.enableEdgeToEdge
20+
import androidx.activity.result.contract.ActivityResultContract
21+
import androidx.activity.result.contract.ActivityResultContracts
22+
import androidx.appcompat.app.AppCompatActivity
23+
import androidx.appcompat.graphics.drawable.DrawerArrowDrawable
24+
import androidx.appcompat.widget.Toolbar
25+
import androidx.constraintlayout.widget.ConstraintLayout
26+
import androidx.core.view.ViewCompat
27+
import androidx.core.view.WindowInsetsCompat
28+
import androidx.core.view.updateLayoutParams
29+
import com.canhub.cropper.CropImageOptions
30+
import com.canhub.cropper.CropImageView
31+
import kotlinx.parcelize.IgnoredOnParcel
32+
import kotlinx.parcelize.Parcelize
33+
import org.fcitx.fcitx5.android.R
34+
import org.fcitx.fcitx5.android.utils.item
35+
import org.fcitx.fcitx5.android.utils.parcelable
36+
import org.fcitx.fcitx5.android.utils.subMenu
37+
import org.fcitx.fcitx5.android.utils.toast
38+
import splitties.dimensions.dp
39+
import splitties.resources.styledColor
40+
import splitties.views.backgroundColor
41+
import splitties.views.dsl.constraintlayout.below
42+
import splitties.views.dsl.constraintlayout.bottomOfParent
43+
import splitties.views.dsl.constraintlayout.centerHorizontally
44+
import splitties.views.dsl.constraintlayout.constraintLayout
45+
import splitties.views.dsl.constraintlayout.lParams
46+
import splitties.views.dsl.constraintlayout.topOfParent
47+
import splitties.views.dsl.core.add
48+
import splitties.views.dsl.core.matchParent
49+
import splitties.views.dsl.core.view
50+
import splitties.views.dsl.core.wrapContent
51+
import splitties.views.topPadding
52+
import timber.log.Timber
53+
import java.io.File
54+
55+
class CropImageActivity : AppCompatActivity(), CropImageView.OnCropImageCompleteListener {
56+
57+
companion object {
58+
const val CROP_OPTIONS = "crop_options"
59+
const val CROP_RESULT = "crop_result"
60+
}
61+
62+
sealed class CropOption() : Parcelable {
63+
abstract val width: Int
64+
abstract val height: Int
65+
66+
@Parcelize
67+
data class New(override val width: Int, override val height: Int) : CropOption()
68+
69+
@Parcelize
70+
data class Edit(
71+
override val width: Int,
72+
override val height: Int,
73+
val sourceUri: Uri,
74+
val initialRect: Rect? = null,
75+
val initialRotation: Int = 0
76+
) : CropOption()
77+
}
78+
79+
sealed class CropResult : Parcelable {
80+
@Parcelize
81+
data object Fail : CropResult()
82+
83+
@Parcelize
84+
data class Success(
85+
val rect: Rect,
86+
val rotation: Int,
87+
val file: File,
88+
val srcUri: Uri
89+
) : CropResult() {
90+
@IgnoredOnParcel
91+
private var _bitmap: Bitmap? = null
92+
val bitmap: Bitmap
93+
get() {
94+
_bitmap?.let { return it }
95+
return BitmapFactory.decodeFile(file.path).also {
96+
_bitmap = it
97+
file.delete()
98+
}
99+
}
100+
}
101+
}
102+
103+
class CropContract : ActivityResultContract<CropOption, CropResult>() {
104+
override fun createIntent(context: Context, input: CropOption): Intent {
105+
return Intent(context, CropImageActivity::class.java).putExtra(CROP_OPTIONS, input)
106+
}
107+
108+
override fun parseResult(resultCode: Int, intent: Intent?): CropResult {
109+
val result = intent?.parcelable<CropResult.Success>(CROP_RESULT)
110+
if (resultCode != RESULT_OK || result == null) {
111+
return CropResult.Fail
112+
}
113+
return result
114+
}
115+
}
116+
117+
private lateinit var cropOption: CropOption
118+
119+
private lateinit var root: ConstraintLayout
120+
private lateinit var toolbar: Toolbar
121+
private lateinit var cropView: CropImageView
122+
123+
private fun getDefaultCropImageOptions() = CropImageOptions(
124+
// CropImageView
125+
snapRadius = 0f,
126+
guidelines = CropImageView.Guidelines.ON_TOUCH,
127+
showProgressBar = true,
128+
progressBarColor = styledColor(android.R.attr.colorAccent),
129+
// CropOverlayView
130+
borderLineThickness = dp(1f),
131+
borderCornerOffset = 0f,
132+
)
133+
134+
private var selectedImageUri: Uri? = null
135+
136+
private val launcher = registerForActivityResult(ActivityResultContracts.GetContent()) { uri ->
137+
if (uri == null) {
138+
setResult(RESULT_CANCELED)
139+
finish()
140+
} else {
141+
selectedImageUri = uri
142+
cropView.setImageUriAsync(uri)
143+
}
144+
}
145+
146+
private lateinit var tempOutFile: File
147+
148+
override fun onCreate(savedInstanceState: Bundle?) {
149+
super.onCreate(savedInstanceState)
150+
cropOption = intent.parcelable<CropOption>(CROP_OPTIONS) ?: CropOption.New(1, 1)
151+
enableEdgeToEdge()
152+
setupRootView()
153+
setContentView(root)
154+
setupCropView(cropOption)
155+
onBackPressedDispatcher.addCallback {
156+
setResult(RESULT_CANCELED)
157+
finish()
158+
}
159+
toolbar.setNavigationOnClickListener {
160+
onBackPressedDispatcher.onBackPressed()
161+
}
162+
}
163+
164+
private fun setupRootView() {
165+
toolbar = view(::Toolbar) {
166+
backgroundColor = styledColor(android.R.attr.colorPrimary)
167+
elevation = dp(4f)
168+
navigationIcon = DrawerArrowDrawable(context).apply { progress = 1f }
169+
setupToolbarMenu(menu)
170+
}
171+
cropView = CropImageView(this).apply {
172+
setOnCropImageCompleteListener(this@CropImageActivity)
173+
setImageCropOptions(getDefaultCropImageOptions())
174+
}
175+
root = constraintLayout {
176+
add(toolbar, lParams(matchParent, wrapContent) {
177+
topOfParent()
178+
centerHorizontally()
179+
})
180+
add(cropView, lParams(matchParent) {
181+
below(toolbar)
182+
centerHorizontally()
183+
bottomOfParent()
184+
})
185+
}
186+
ViewCompat.setOnApplyWindowInsetsListener(root) { _, windowInsets ->
187+
val statusBars = windowInsets.getInsets(WindowInsetsCompat.Type.statusBars())
188+
val navBars = windowInsets.getInsets(WindowInsetsCompat.Type.navigationBars())
189+
root.updateLayoutParams<ViewGroup.MarginLayoutParams> {
190+
leftMargin = navBars.left
191+
rightMargin = navBars.right
192+
bottomMargin = navBars.bottom
193+
}
194+
toolbar.topPadding = statusBars.top
195+
windowInsets
196+
}
197+
}
198+
199+
private fun setupToolbarMenu(menu: Menu) {
200+
val iconTint = styledColor(android.R.attr.colorControlNormal)
201+
menu.item(R.string.rotate, R.drawable.ic_baseline_rotate_right_24, iconTint, true) {
202+
cropView.rotateImage(90)
203+
}
204+
menu.subMenu(R.string.flip, R.drawable.ic_baseline_flip_24, iconTint, true) {
205+
item(R.string.flip_vertically) {
206+
cropView.flipImageVertically()
207+
}
208+
item(R.string.flip_horizontally) {
209+
cropView.flipImageHorizontally()
210+
}
211+
}
212+
menu.item(R.string.crop, R.drawable.ic_baseline_check_24, iconTint, true) {
213+
onCropImage()
214+
}
215+
}
216+
217+
private fun setupCropView(option: CropOption) {
218+
cropView.setAspectRatio(option.width, option.height)
219+
when (option) {
220+
is CropOption.New -> {
221+
launcher.launch("image/*")
222+
}
223+
is CropOption.Edit -> {
224+
cropView.setOnSetImageUriCompleteListener { view, uri, e ->
225+
view.cropRect = option.initialRect
226+
view.rotatedDegrees = option.initialRotation
227+
}
228+
cropView.setImageUriAsync(option.sourceUri)
229+
}
230+
}
231+
}
232+
233+
private fun onCropImage() {
234+
tempOutFile = File.createTempFile("cropped", ".png", cacheDir)
235+
cropView.croppedImageAsync(
236+
saveCompressFormat = Bitmap.CompressFormat.PNG,
237+
reqWidth = cropOption.width,
238+
reqHeight = cropOption.height,
239+
options = CropImageView.RequestSizeOptions.RESIZE_INSIDE,
240+
customOutputUri = Uri.fromFile(tempOutFile)
241+
)
242+
}
243+
244+
override fun onCropImageComplete(view: CropImageView, result: CropImageView.CropResult) {
245+
try {
246+
result
247+
val success = CropResult.Success(
248+
result.cropRect!!,
249+
result.rotation,
250+
tempOutFile,
251+
(cropOption as? CropOption.Edit)?.sourceUri ?: selectedImageUri!!
252+
)
253+
setResult(RESULT_OK, Intent().putExtra(CROP_RESULT, success))
254+
} catch (e: Exception) {
255+
Timber.e("Exception when cropping image: $e")
256+
toast(e)
257+
setResult(RESULT_CANCELED)
258+
}
259+
finish()
260+
}
261+
}

0 commit comments

Comments
 (0)