diff --git a/app/build.gradle b/app/build.gradle
index d212d8a1..65ba05e5 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -149,7 +149,7 @@ dependencies {
implementation "io.github.ParkSangGwon:tedpermission-normal:3.3.0"
implementation 'com.github.dhaval2404:imagepicker:2.1'
- implementation 'com.github.yalantis:ucrop:2.2.10'
+ implementation 'com.github.yalantis:ucrop:2.2.11'
implementation 'com.github.zhpanvip:bannerviewpager:3.5.7'
implementation 'com.google.android.gms:play-services-oss-licenses:17.1.0'
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
index 1afcbf88..adf67939 100644
--- a/app/proguard-rules.pro
+++ b/app/proguard-rules.pro
@@ -239,3 +239,7 @@
-dontwarn org.openjsse.**
-keep interface kr.co.vividnext.sodalive.tracking.UserEventApi
+
+-dontwarn com.yalantis.ucrop**
+-keep class com.yalantis.ucrop** { *; }
+-keep interface com.yalantis.ucrop** { *; }
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index ae63af07..3630dfb4 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -289,5 +289,17 @@
+
+
+
+
+
+
diff --git a/app/src/main/java/kr/co/vividnext/sodalive/common/ImagePickerCropper.kt b/app/src/main/java/kr/co/vividnext/sodalive/common/ImagePickerCropper.kt
new file mode 100644
index 00000000..1b74d517
--- /dev/null
+++ b/app/src/main/java/kr/co/vividnext/sodalive/common/ImagePickerCropper.kt
@@ -0,0 +1,151 @@
+package kr.co.vividnext.sodalive.common
+
+import android.app.Activity
+import android.content.Context
+import android.graphics.Bitmap
+import android.net.Uri
+import androidx.activity.result.ActivityResultCaller
+import androidx.activity.result.ActivityResultLauncher
+import androidx.activity.result.PickVisualMediaRequest
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.core.content.FileProvider
+import com.yalantis.ucrop.UCrop
+import com.yalantis.ucrop.UCropActivity
+import kr.co.vividnext.sodalive.BuildConfig
+import java.io.File
+
+/**
+ * 단일 이미지 선택(13+ Photo Picker / 12- GetContent) → uCrop(기본 9:20) → [File, Uri] 반환
+ * - 갤러리만 사용(카메라 X) → 런타임 권한 불필요
+ * - 결과 파일은 cacheDir에 임시 생성 → 업로드 후 cleanup() 호출로 삭제
+ */
+class ImagePickerCropper(
+ private val caller: ActivityResultCaller,
+ private val context: Context,
+ private val config: Config = Config(),
+ private val onSuccess: (file: File, uri: Uri) -> Unit,
+ private val onError: (Throwable) -> Unit = { it.printStackTrace() }
+) {
+ data class Config(
+ val aspectX: Float = 9f, // 고정 비율: 가로
+ val aspectY: Float = 20f, // 고정 비율: 세로
+ val maxWidth: Int? = null, // 예: 1080
+ val maxHeight: Int? = null, // 예: 2400
+ val compressFormat: Bitmap.CompressFormat = Bitmap.CompressFormat.JPEG,
+ val compressQuality: Int = 90, // 0~100
+ val useExternalCache: Boolean = false // 외부 캐시 사용 여부
+ )
+
+ private var lastCroppedFile: File? = null
+
+ // 13+ : 시스템 Photo Picker
+ private val pickPhoto: ActivityResultLauncher =
+ caller.registerForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri ->
+ if (uri == null) onError(CancellationException("User cancelled picking."))
+ else startCrop(uri)
+ }
+
+ // 12- : SAF GetContent
+ private val pickContent: ActivityResultLauncher =
+ caller.registerForActivityResult(ActivityResultContracts.GetContent()) { uri ->
+ if (uri == null) onError(CancellationException("User cancelled picking."))
+ else startCrop(uri)
+ }
+
+ // uCrop 결과 수신
+ private val cropResult =
+ caller.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
+ when (result.resultCode) {
+ Activity.RESULT_OK -> {
+ val out = UCrop.getOutput(result.data!!)
+ val file = lastCroppedFile
+ if (out != null && file != null && file.exists()) {
+ onSuccess(file, out)
+ } else {
+ onError(IllegalStateException("Crop finished but no output file/uri"))
+ }
+ }
+
+ UCrop.RESULT_ERROR -> onError(
+ UCrop.getError(result.data!!) ?: RuntimeException("Crop error")
+ )
+
+ else -> onError(CancellationException("User cancelled cropping."))
+ }
+ }
+
+ /** 외부에서 호출: 선택 → 크롭 시작 */
+ fun launch() {
+ if (ActivityResultContracts.PickVisualMedia.isPhotoPickerAvailable(context)) {
+ pickPhoto.launch(
+ PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
+ )
+ } else {
+ pickContent.launch("image/*")
+ }
+ }
+
+ /** 마지막 크롭 결과 파일 (있으면) */
+ fun getCroppedFile(): File? = lastCroppedFile?.takeIf { it.exists() }
+
+ /** 임시 파일 삭제 */
+ fun cleanup() {
+ lastCroppedFile?.let { if (it.exists()) it.delete() }
+ lastCroppedFile = null
+ }
+
+ private fun startCrop(source: Uri) {
+ val destFile = createTempCropFile()
+ lastCroppedFile = destFile
+
+ val destUri = FileProvider.getUriForFile(
+ context,
+ "${BuildConfig.APPLICATION_ID}.fileprovider", // ★ Manifest와 동일
+ destFile
+ )
+
+ val options = UCrop.Options().apply {
+ // 압축 포맷 & 품질 (uCrop 2.2.11 기준)
+ setCompressionFormat(config.compressFormat)
+ setCompressionQuality(config.compressQuality)
+
+ // 제스처/컨트롤 (필요시 조절)
+ setFreeStyleCropEnabled(false)
+ setHideBottomControls(false)
+ setAllowedGestures(
+ UCropActivity.SCALE, // Aspect 줄이진 못하지만 확대/축소
+ UCropActivity.ROTATE, // 회전
+ UCropActivity.ALL
+ )
+
+ // (선택) UI 커스텀: 툴바/상태바/위젯 컬러 등
+ // setToolbarColor(...)
+ // setStatusBarColor(...)
+ // setActiveControlsWidgetColor(...)
+ }
+
+ var u = UCrop.of(source, destUri)
+ .withAspectRatio(config.aspectX, config.aspectY)
+ .withOptions(options)
+
+ if (config.maxWidth != null && config.maxHeight != null) {
+ u = u.withMaxResultSize(config.maxWidth, config.maxHeight)
+ }
+
+ cropResult.launch(u.getIntent(context))
+ }
+
+ private fun createTempCropFile(): File {
+ val base = if (config.useExternalCache) context.externalCacheDir ?: context.cacheDir
+ else context.cacheDir
+
+ val ext = when (config.compressFormat) {
+ Bitmap.CompressFormat.PNG -> "png"
+ Bitmap.CompressFormat.WEBP -> "webp" // 일부 기기에서 deprecated 경고 가능
+ else -> "jpg" // JPEG default
+ }
+ return File(base, "crop_${System.currentTimeMillis()}.$ext")
+ }
+}
+
+private class CancellationException(msg: String) : RuntimeException(msg)
diff --git a/app/src/main/java/kr/co/vividnext/sodalive/live/room/create/LiveRoomCreateActivity.kt b/app/src/main/java/kr/co/vividnext/sodalive/live/room/create/LiveRoomCreateActivity.kt
index 2cdb31bf..551dd411 100644
--- a/app/src/main/java/kr/co/vividnext/sodalive/live/room/create/LiveRoomCreateActivity.kt
+++ b/app/src/main/java/kr/co/vividnext/sodalive/live/room/create/LiveRoomCreateActivity.kt
@@ -4,6 +4,7 @@ import android.annotation.SuppressLint
import android.app.DatePickerDialog
import android.app.TimePickerDialog
import android.content.Intent
+import android.graphics.Bitmap
import android.os.Bundle
import android.os.Handler
import android.os.Looper
@@ -13,20 +14,19 @@ import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import android.widget.Toast
-import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.core.content.res.ResourcesCompat
-import coil.load
-import coil.transform.RoundedCornersTransformation
-import com.github.dhaval2404.imagepicker.ImagePicker
+import com.bumptech.glide.Glide
+import com.bumptech.glide.load.resource.bitmap.RoundedCorners
+import com.bumptech.glide.request.RequestOptions
import com.jakewharton.rxbinding4.widget.textChanges
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.schedulers.Schedulers
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.common.Constants
+import kr.co.vividnext.sodalive.common.ImagePickerCropper
import kr.co.vividnext.sodalive.common.LoadingDialog
-import kr.co.vividnext.sodalive.common.RealPathUtil
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.databinding.ActivityLiveRoomCreateBinding
import kr.co.vividnext.sodalive.databinding.ItemLiveTagSelectedBinding
@@ -47,6 +47,7 @@ class LiveRoomCreateActivity : BaseActivity(
private val viewModel: LiveRoomCreateViewModel by inject()
private lateinit var loadingDialog: LoadingDialog
+ private lateinit var cropper: ImagePickerCropper
private val handler = Handler(Looper.getMainLooper())
@@ -95,34 +96,8 @@ class LiveRoomCreateActivity : BaseActivity(
}
}
- private val imageResult =
- registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
- val resultCode = result.resultCode
- val data = result.data
-
- if (resultCode == RESULT_OK) {
- // Image Uri will not be null for RESULT_OK
- val fileUri = data?.data!!
- binding.ivCover.background = null
- binding.ivCover.load(fileUri) {
- crossfade(true)
- placeholder(R.drawable.ic_place_holder)
- transformations(RoundedCornersTransformation(13.3f.dpToPx()))
- }
- viewModel.coverImageUri = fileUri
- viewModel.coverImagePath = null
- } else if (resultCode == ImagePicker.RESULT_ERROR) {
- Toast.makeText(this, ImagePicker.getError(data), Toast.LENGTH_SHORT).show()
- }
- }
-
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
-
- viewModel.getRealPathFromURI = {
- RealPathUtil.getRealPath(applicationContext, it)
- }
-
bindData()
viewModel.setTimeNow(
@@ -132,25 +107,48 @@ class LiveRoomCreateActivity : BaseActivity(
viewModel.getAllMenuPreset()
}
+ override fun onDestroy() {
+ cropper.cleanup()
+ super.onDestroy()
+ }
+
@SuppressLint("SetTextI18n", "ClickableViewAccessibility")
override fun setupView() {
loadingDialog = LoadingDialog(this, layoutInflater)
+ cropper = ImagePickerCropper(
+ caller = this,
+ context = this,
+ config = ImagePickerCropper.Config(
+ aspectX = 2f, aspectY = 3.8f,
+ maxWidth = 1080, maxHeight = 2052,
+ compressFormat = Bitmap.CompressFormat.JPEG,
+ compressQuality = 90
+ ),
+ onSuccess = { file, uri ->
+ binding.ivCover.background = null
+ Glide.with(binding.ivCover.context)
+ .load(uri)
+ .placeholder(R.drawable.ic_place_holder)
+ .apply(
+ RequestOptions().transform(
+ RoundedCorners(
+ 16f.dpToPx().toInt()
+ )
+ )
+ )
+ .into(binding.ivCover)
+
+ viewModel.coverImageFile = file
+ viewModel.coverImagePath = null
+ },
+ onError = { e ->
+ Toast.makeText(this, "${e.message}", Toast.LENGTH_SHORT).show()
+ }
+ )
binding.tvBack.setOnClickListener { finish() }
- binding.ivPhotoPicker.setOnClickListener {
- ImagePicker.with(this)
- .crop()
- .galleryOnly()
- .galleryMimeTypes( // Exclude gif images
- mimeTypes = arrayOf(
- "image/png",
- "image/jpg",
- "image/jpeg"
- )
- )
- .createIntent { imageResult.launch(it) }
- }
+ binding.ivPhotoPicker.setOnClickListener { cropper.launch() }
binding.llOpen.setOnClickListener {
viewModel.setRoomType(LiveRoomType.OPEN)
@@ -239,6 +237,8 @@ class LiveRoomCreateActivity : BaseActivity(
binding.tvMakeRoom.setOnClickListener {
binding.tvMakeRoom.isEnabled = false
viewModel.createLiveRoom {
+ cropper.cleanup()
+
val intent = Intent()
if (it.id != null) {
intent.putExtra(Constants.EXTRA_ROOM_ID, it.id)
@@ -283,11 +283,17 @@ class LiveRoomCreateActivity : BaseActivity(
binding.etNotice.setText(it.notice)
binding.etNumberOfPeople.setText(it.numberOfPeople.toString())
binding.ivCover.background = null
- binding.ivCover.load(it.coverImageUrl) {
- crossfade(true)
- placeholder(R.drawable.ic_place_holder)
- transformations(RoundedCornersTransformation(13.3f.dpToPx()))
- }
+ Glide.with(binding.ivCover.context)
+ .load(it.coverImageUrl)
+ .placeholder(R.drawable.ic_place_holder)
+ .apply(
+ RequestOptions().transform(
+ RoundedCorners(
+ 16f.dpToPx().toInt()
+ )
+ )
+ )
+ .into(binding.ivCover)
}
}
@@ -631,7 +637,7 @@ class LiveRoomCreateActivity : BaseActivity(
viewModel.selectedMenuLiveData.observe(this) {
deselectAllMenuPreset()
- when(it) {
+ when (it) {
LiveRoomCreateViewModel.SelectedMenu.MENU_2 -> selectMenuPresetButton(
binding.ivSelectMenu2,
binding.llSelectMenu2,
diff --git a/app/src/main/java/kr/co/vividnext/sodalive/live/room/create/LiveRoomCreateViewModel.kt b/app/src/main/java/kr/co/vividnext/sodalive/live/room/create/LiveRoomCreateViewModel.kt
index cba79db0..c2970c71 100644
--- a/app/src/main/java/kr/co/vividnext/sodalive/live/room/create/LiveRoomCreateViewModel.kt
+++ b/app/src/main/java/kr/co/vividnext/sodalive/live/room/create/LiveRoomCreateViewModel.kt
@@ -79,15 +79,13 @@ class LiveRoomCreateViewModel(
val menuLiveData: LiveData
get() = _menuLiveData
- lateinit var getRealPathFromURI: (Uri) -> String?
-
var title = ""
var content = ""
var numberOfPeople = 0
var tags = mutableSetOf()
var beginDate = ""
var beginTime = ""
- var coverImageUri: Uri? = null
+ var coverImageFile: File? = null
var coverImagePath: String? = null
var password: String? = null
@@ -157,8 +155,8 @@ class LiveRoomCreateViewModel(
val requestJson = Gson().toJson(request)
- val coverImage = if (coverImageUri != null) {
- val file = File(getRealPathFromURI(coverImageUri!!))
+ val coverImage = if (coverImageFile != null) {
+ val file = coverImageFile!!
MultipartBody.Part.createFormData(
"coverImage",
file.name,
@@ -226,7 +224,7 @@ class LiveRoomCreateViewModel(
return false
}
- if (coverImageUri == null && coverImagePath == null) {
+ if (coverImageFile == null && coverImagePath == null) {
_toastLiveData.postValue("커버이미지를 선택해주세요.")
return false
}
@@ -273,7 +271,7 @@ class LiveRoomCreateViewModel(
.subscribe(
{
if (it.success && it.data != null) {
- coverImageUri = null
+ coverImageFile = null
coverImagePath = it.data.coverImagePath
onSuccess(it.data!!)
diff --git a/app/src/main/res/layout/activity_live_room_create.xml b/app/src/main/res/layout/activity_live_room_create.xml
index 70a57060..0e1dd7a6 100644
--- a/app/src/main/res/layout/activity_live_room_create.xml
+++ b/app/src/main/res/layout/activity_live_room_create.xml
@@ -62,35 +62,38 @@
android:layout_marginHorizontal="13.3dp"
android:layout_marginTop="13.3dp"
android:fontFamily="@font/gmarket_sans_bold"
- android:text="썸네일"
+ android:text="배경"
android:textColor="@color/color_eeeeee"
android:textSize="16.7sp" />
-
+ android:src="@drawable/ic_logo"
+ app:layout_constraintDimensionRatio="2:3.8"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toTopOf="parent" />
-
+ android:src="@drawable/ic_camera"
+ app:layout_constraintBottom_toBottomOf="@+id/iv_cover"
+ app:layout_constraintStart_toEndOf="@+id/iv_cover" />
+
+
+
+
+
+