이번 포스팅에서는 마이페이지에서 본인의 프로필 사진을 변경할 수 있는 기능을 추가해보도록 하겠습니다. 오늘 다룰 기능은 마이페이지의 기능 중 가장 난이도가 높은 부분임과 동시에, 반드시 구현할 수 있어야 할 중요한 기능이 아닐까 싶습니다. 이번 포스팅의 내용을 제대로 이해하기 위해서는 (백엔드 측에서) AWS S3를 사용할 수 있어야 합니다. S3 사용법이 궁금하신 분들은 아래의 링크를 참조해주세요.
>> AWS S3 사용법
① UserController에 아래의 API를 추가한다.
/**
* 유저 프로필 사진 변경
*/
@PatchMapping("/profile")
public BaseResponse<String> modifyProfile(@RequestPart(value = "image", required = false) MultipartFile multipartFile) {
try {
Long userId = jwtService.getUserIdx();
return new BaseResponse<>(userService.modifyProfile(userId, multipartFile));
} catch (BaseException exception) {
return new BaseResponse<>(exception.getStatus());
}
}
/**
* 기본 프로필 사진 적용
*/
@PatchMapping("/noProfile")
public BaseResponse<String> modifyNoProfile() {
try {
Long userId = jwtService.getUserIdx();
return new BaseResponse<>(userService.modifyNoProfile(userId));
} catch (BaseException exception) {
return new BaseResponse<>(exception.getStatus());
}
}
② UserService에 아래의 메서드를 추가한다.
/**
* 유저 프로필 변경
*/
@Transactional
public String modifyProfile(Long userId, MultipartFile multipartFile) throws BaseException {
try {
User user = utilService.findByUserIdWithValidation(userId);
Profile profile = profileRepository.findProfileById(userId).orElse(null);
if(profile == null) { // 프로필이 미등록된 사용자가 변경을 요청하는 경우
GetS3Res getS3Res;
if(multipartFile != null) {
getS3Res = s3Service.uploadSingleFile(multipartFile);
profileService.saveProfile(getS3Res, user);
}
}
else { // 프로필이 등록된 사용자가 변경을 요청하는 경우
// 1. 버킷에서 삭제
profileService.deleteProfile(profile);
// 2. Profile Repository에서 삭제
profileService.deleteProfileById(userId);
if(multipartFile != null) {
GetS3Res getS3Res = s3Service.uploadSingleFile(multipartFile);
profileService.saveProfile(getS3Res, user);
}
}
return "프로필 수정이 완료되었습니다.";
} catch (BaseException exception) {
throw new BaseException(exception.getStatus());
}
}
/**
* 기본 프로필 적용
*/
@Transactional
public String modifyNoProfile(Long userId) throws BaseException {
try {
User user = utilService.findByUserIdWithValidation(userId);
Profile profile = profileRepository.findProfileById(userId).orElse(null);
if(profile != null) { // 프로필이 미등록된 사용자가 변경을 요청하는 경우
// 1. 버킷에서 삭제
profileService.deleteProfile(profile);
// 2. Profile Repository에서 삭제
profileService.deleteProfileById(userId);
}
return "기본 프로필이 적용됩니다.";
} catch (BaseException exception) {
throw new BaseException(exception.getStatus());
}
}
① mypage 패키지 하위로, ProfileActivity를 생성한다.
② activity_profile.xml 파일에 아래의 내용을 입력한다.
<?xml version="1.0" encoding="utf-8"?>
<ScrollView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/main_border"
tools:context=".mypage.ProfileActivity">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<ImageView
android:id="@+id/imageView"
android:layout_width="match_parent"
android:layout_height="100dp"
android:layout_marginBottom="70dp"
android:src="@drawable/mypage"/>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<ImageView
android:id="@+id/profile"
android:layout_width="200dp"
android:layout_height="200dp"
android:src="@drawable/profile"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
<Button
android:id="@+id/newProfileBtn"
android:layout_width="match_parent"
android:layout_height="70dp"
android:layout_marginTop="70dp"
android:layout_marginHorizontal="40dp"
android:background="@color/skyBlue"
android:text="프로필 변경"
android:textSize="35sp" />
<Button
android:id="@+id/noProfileBtn"
android:layout_width="match_parent"
android:layout_height="70dp"
android:layout_marginVertical="50dp"
android:layout_marginHorizontal="40dp"
android:background="@color/skyBlue"
android:text="기본 이미지로 설정"
android:textSize="35sp" />
</LinearLayout>
</ScrollView>
③ 마이페이지에서 프로필 변경 버튼을 클릭했을 때, 프로필 변경 페이지로 전환되도록 MyPageFragment의 onCreateView에 아래의 내용을 추가한다.
lateinit var profileImage : ImageView
...
override fun onCreateView(
...
val profile = view.findViewById<Button>(R.id.profileBtn)
profile.setOnClickListener {
val intent = Intent(requireActivity(), ProfileActivity::class.java)
startActivity(intent)
}
④ MyPageApi 인터페이스에 아래의 API를 추가한다.
@Multipart
@PATCH("/users/profile")
suspend fun modifyProfile(
@Header("Authorization") accessToken : String,
@Part image : MultipartBody.Part?
): BaseResponse<String>
@PATCH("/users/noProfile")
suspend fun modifyNoProfile(
@Header("Authorization") accessToken : String
): BaseResponse<String>
⑤ 프로필 이미지를 불러오기 위해서는 Glide 라이브러리를 사용해야 한다. 아래의 의존성을 Module 수준의 build.gradle의 dependencies에 추가하자.
implementation("com.github.bumptech.glide:glide:4.12.0")
annotationProcessor("com.github.bumptech.glide:compiler:4.12.0")
⑥ Module 수준의 build.gradle의 defaultConfig에 아래의 내용을 추가한다.
multiDexEnabled = true
⑦ ProfileActivity를 아래와 같이 수정한다.
class ProfileActivity : AppCompatActivity() {
private var selectedImageUri: Uri? = null
lateinit var profileUrl : String
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_profile)
val newProfileBtn = findViewById<Button>(R.id.newProfileBtn)
val profileImage = findViewById<ImageView>(R.id.profile)
val noProfileBtn = findViewById<Button>(R.id.noProfileBtn)
CoroutineScope(Dispatchers.IO).launch {
val response = getUserInfo(FirebaseAuthUtils.getUid())
if (response.isSuccess) {
if(response.result?.imgUrl != null) {
this@ProfileActivity.profileUrl = response.result?.imgUrl
val profileUri = Uri.parse(profileUrl)
runOnUiThread {
Glide.with(this@ProfileActivity)
.load(profileUri)
.into(profileImage)
}
}
} else {
Log.d("ProfileActivity", "유저의 정보를 불러오지 못함")
}
}
val getAction = registerForActivityResult(
ActivityResultContracts.GetContent(),
ActivityResultCallback { uri ->
profileImage.setImageURI(uri)
selectedImageUri = uri
}
)
profileImage.setOnClickListener {
getAction.launch("image/*")
}
noProfileBtn.setOnClickListener {
getAccessToken { accessToken ->
if (accessToken.isNotEmpty()) {
CoroutineScope(Dispatchers.IO).launch {
val response = modifyNoProfile(accessToken)
if (response.isSuccess) {
withContext(Dispatchers.Main) {
Toast.makeText(this@ProfileActivity, "기본 프로필이 적용됩니다", Toast.LENGTH_SHORT).show()
val intent = Intent(this@ProfileActivity, MainActivity::class.java)
startActivity(intent)
}
} else {
Log.d("ProfileActivity", "프로필 변경 실패")
val message = response.message
Log.d("ProfileActivity", message)
withContext(Dispatchers.Main) {
Toast.makeText(this@ProfileActivity, message, Toast.LENGTH_SHORT).show()
}
}
}
} else {
Log.e("ProfileActivity", "Invalid Token")
}
}
}
newProfileBtn.setOnClickListener {
if (selectedImageUri != null) {
getAccessToken { accessToken ->
if (accessToken.isNotEmpty()) {
Log.d("ProfileActivity", selectedImageUri.toString())
CoroutineScope(Dispatchers.IO).launch {
val response = modifyProfile(accessToken, selectedImageUri)
Log.d("ProfileActivity", response.result.toString())
if (response.isSuccess) {
withContext(Dispatchers.Main) {
Toast.makeText(this@ProfileActivity, "프로필 변경이 완료되었습니다", Toast.LENGTH_SHORT).show()
val intent = Intent(this@ProfileActivity, MainActivity::class.java)
startActivity(intent)
}
} else {
Log.d("ProfileActivity", "프로필 변경 실패")
val message = response.message
Log.d("ProfileActivity", message)
withContext(Dispatchers.Main) {
Toast.makeText(this@ProfileActivity, message, Toast.LENGTH_SHORT).show()
}
}
}
} else {
Log.e("ProfileActivity", "Invalid Token")
}
}
} else {
Toast.makeText(this@ProfileActivity, "새로운 이미지를 선택해주세요.", Toast.LENGTH_SHORT).show()
}
}
}
private suspend fun getUserInfo(uid: String): BaseResponse<GetUserRes> {
return RetrofitInstance.myPageApi.getUserInfo(uid)
}
private suspend fun modifyProfile(accessToken : String, uri: Uri?): BaseResponse<String> {
// Create a RequestBody from the image file
val imagePath = getImagePathFromUri(uri!!)
val imageFile = File(imagePath)
Log.d("ProfileActivity", "path : " + imagePath.toString())
Log.d("ProfileActivity", "file : " + imageFile.toString())
val requestFile = RequestBody.create(MediaType.parse("multipart/form-data"), imageFile)
val imagePart = MultipartBody.Part.createFormData("image", imageFile.name, requestFile)
return RetrofitInstance.myPageApi.modifyProfile(accessToken, imagePart)
}
private suspend fun modifyNoProfile(accessToken : String) : BaseResponse<String> {
return RetrofitInstance.myPageApi.modifyNoProfile(accessToken)
}
private fun getAccessToken(callback: (String) -> Unit) {
val postListener = object : ValueEventListener {
override fun onDataChange(dataSnapshot: DataSnapshot) {
val data = dataSnapshot.getValue(com.chrome.chattingapp.authentication.UserInfo::class.java)
val accessToken = data?.accessToken ?: ""
callback(accessToken)
}
override fun onCancelled(databaseError: DatabaseError) {
Log.w("ProfileActivity", "onCancelled", databaseError.toException())
}
}
FirebaseRef.userInfo.child(FirebaseAuthUtils.getUid()).addListenerForSingleValueEvent(postListener)
}
private fun getImagePathFromUri(uri: Uri): String? {
val projection = arrayOf(MediaStore.Images.Media.DATA)
val cursor = contentResolver.query(uri, projection, null, null, null)
cursor?.moveToFirst()
val columnIndex = cursor?.getColumnIndexOrThrow(MediaStore.Images.Media.DATA)
val imagePath = cursor?.getString(columnIndex!!)
cursor?.close()
return imagePath
}
}
이제 코드를 실행시켜보면, 프로필 이미지를 클릭해 휴대폰의 사진을 불러올 수 있으며, 프로필 변경 버튼을 눌러 해당 사진을 본인의 프로필 사진으로 등록할 수 있다. 또한 기본 이미지로 설정하여 No Profile로 변경할 수도 있다.