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" /> + + + + + +