diff --git a/app/src/main/kotlin/bums/lunatic/launcher/BookmarkUploader.kt b/app/src/main/kotlin/bums/lunatic/launcher/BookmarkUploader.kt new file mode 100644 index 00000000..cdd05079 --- /dev/null +++ b/app/src/main/kotlin/bums/lunatic/launcher/BookmarkUploader.kt @@ -0,0 +1,236 @@ +package bums.lunatic.launcher + +// ui/bookmark/BookmarkUploader.kt + +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.logging.HttpLoggingInterceptor + +object OkHttpClientInstance { + + val client: OkHttpClient by lazy { + // 네트워크 로그를 보기 위한 인터셉터 설정 + val loggingInterceptor = HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.BODY + } + + OkHttpClient.Builder() + .addInterceptor(loggingInterceptor) + .build() + } +} + +object BookmarkUploader { + + // 로그인 성공 시 서버로부터 받은 JWT 토큰을 저장할 변수 + val isUserLoggedIn: Boolean + get() = userJwtToken != null + + private var userJwtToken: String? = null + + private val baseUrl = "https://lunaticbum.kr" + private val loginUrl = "$baseUrl/api/auth/login" + private val saveBookmarkUrl = "$baseUrl/bookmarks" + private val saveBookmarkWithImageUrl = "$baseUrl/api/bookmarks/with-image" + + /** + * 로그인 API를 호출하여 JWT 토큰을 받아오는 함수 (콜백 추가) + * @param onSuccess 로그인 성공 시 실행될 콜백 함수 + * @param onError 로그인 실패 시 실행될 콜백 함수 + */ + fun loginAndGetToken( + userId: String, + userPw: String, + onSuccess: () -> Unit, + onError: (String) -> Unit + ) { + CoroutineScope(Dispatchers.IO).launch { + try { + val loginDataMap = mapOf("userId" to userId, "userPw" to userPw) + val json = Gson().toJson(loginDataMap) + val requestBody = json.toRequestBody("application/json; charset=utf-8".toMediaType()) + val request = Request.Builder().url(loginUrl).post(requestBody).build() + + val responseJson = OkHttpClientInstance.client.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + throw Exception("로그인 실패: ${response.code} ${response.message}") + } + response.body?.string() + } + + if (responseJson != null) { + val type = object : TypeToken>() {}.type + val resultMap: Map = Gson().fromJson(responseJson, type) + userJwtToken = resultMap["token"] + + if (userJwtToken != null) { + println("✅ 로그인 성공! 토큰을 발급받았습니다.") + withContext(Dispatchers.Main) { onSuccess() } // 성공 콜백 실행 + } else { + val errorMessage = "❌ 로그인 실패: 응답에 토큰이 없습니다." + println(errorMessage) + withContext(Dispatchers.Main) { onError(errorMessage) } // 실패 콜백 실행 + } + } + } catch (e: Exception) { + val errorMessage = "🔥 로그인 중 오류 발생: ${e.message}" + println(errorMessage) + withContext(Dispatchers.Main) { onError(errorMessage) } // 실패 콜백 실행 + } + } + } + + /** + * OkHttpClient를 사용해 새 북마크를 서버에 저장하는 함수 + */ + fun saveBookmarkWithOkHttp(url: String, + selectedImageUrl: String?, comment: String, visibility: String) { + // 토큰이 없으면 함수를 실행하지 않음 (로그인 필요) + if (userJwtToken == null) { + println("🛑 저장을 위해 로그인이 필요합니다.") + // TODO: 사용자에게 로그인하라는 메시지 표시 + return + } + + CoroutineScope(Dispatchers.IO).launch { + try { + val bookmarkDataMap = mutableMapOf( + "url" to url, + "userComment" to comment, + "visibility" to visibility + ) + + if (selectedImageUrl != null) { + bookmarkDataMap["userSelectedImageUrl"] = selectedImageUrl + } + val json = Gson().toJson(bookmarkDataMap) + val requestBody = json.toRequestBody("application/json; charset=utf-8".toMediaType()) + + // "Bearer " 접두사를 포함한 인증 토큰 준비 + val formattedToken = "Bearer $userJwtToken" + + val request = Request.Builder() + .url(saveBookmarkUrl) + .addHeader("Authorization", formattedToken) // 인증 헤더 추가 + .post(requestBody) + .build() + + val responseJson = withContext(Dispatchers.IO) { + OkHttpClientInstance.client.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + throw Exception("API 호출 실패: ${response.code} ${response.message}") + } + response.body?.string() + } + } + + if (responseJson != null) { + val type = object : TypeToken>() {}.type + val resultMap: Map = Gson().fromJson(responseJson, type) + println("✅ 북마크 저장 성공: $resultMap") + } else { + println("❌ 북마크 저장 실패: 응답 본문이 비어있습니다.") + } + + } catch (e: Exception) { + println("🔥 북마크 저장 중 오류 발생: ${e.message}") + } + } + } + + + + fun saveBookmarkWithImageUpload( + pageUrl: String, + selectedImageUrl: String, + comment: String, + visibility: String + ) { + if (userJwtToken == null) { + println("🛑 저장을 위해 로그인이 필요합니다.") + return + } + + CoroutineScope(Dispatchers.IO).launch { + try { + // --- 1단계: 이미지 다운로드 (pageUrl을 referer로 전달) --- + val imageData = downloadImage(selectedImageUrl, pageUrl) // referer 추가 + if (imageData == null) { + println("❌ 이미지 다운로드 실패") + return@launch + } + + // --- 2단계: Multipart 요청 생성 및 업로드 (기존과 동일) --- + val bookmarkDataMap = mapOf( + "url" to pageUrl, + "userComment" to comment, + "visibility" to visibility + ) + val bookmarkDataJson = Gson().toJson(bookmarkDataMap) + + val multipartBody = MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart("bookmarkData", bookmarkDataJson) + .addFormDataPart( + "imageFile", + "user_selected_image.jpg", + imageData.toRequestBody("image/jpeg".toMediaTypeOrNull()) + ) + .build() + + val formattedToken = "Bearer $userJwtToken" + val request = Request.Builder() + .url(saveBookmarkWithImageUrl) + .addHeader("Authorization", formattedToken) + .post(multipartBody) + .build() + + // 백그라운드에서 API 호출 + withContext(Dispatchers.IO) { + OkHttpClientInstance.client.newCall(request).execute().use { response -> + if (response.isSuccessful) { + println("✅ 이미지와 함께 북마크 저장 성공: ${response.body?.string()}") + } else { + println("❌ 이미지와 함께 북마-크 저장 실패: ${response.code}") + } + } + } + + } catch (e: Exception) { + println("🔥 북마크 저장(이미지 포함) 중 오류 발생: ${e.message}") + } + } + } + + /** + * 주어진 URL에서 이미지를 다운로드하여 ByteArray로 반환하는 헬퍼 함수 (Referer 추가) + */ + private suspend fun downloadImage(imageUrl: String, referer: String?): ByteArray? = withContext(Dispatchers.IO) { + try { + val requestBuilder = Request.Builder().url(imageUrl) + // referer가 있으면 헤더에 추가 + if (referer != null) { + requestBuilder.addHeader("Referer", referer) + } + val request = requestBuilder.build() + + OkHttpClientInstance.client.newCall(request).execute().use { response -> + if (!response.isSuccessful) return@withContext null + response.body?.bytes() + } + } catch (e: Exception) { + println("🔥 이미지 다운로드 중 예외 발생: ${e.message}") + null + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/bums/lunatic/launcher/home/GeckoWeb.kt b/app/src/main/kotlin/bums/lunatic/launcher/home/GeckoWeb.kt index 953163a8..21b590d6 100644 --- a/app/src/main/kotlin/bums/lunatic/launcher/home/GeckoWeb.kt +++ b/app/src/main/kotlin/bums/lunatic/launcher/home/GeckoWeb.kt @@ -30,13 +30,17 @@ import android.view.PointerIcon import android.view.View import android.widget.CheckBox import android.widget.EditText +import android.widget.LinearLayout import android.widget.ProgressBar +import android.widget.RadioButton +import android.widget.RadioGroup import android.widget.TextView import android.widget.Toast import androidx.appcompat.app.AlertDialog import androidx.core.content.ContextCompat.getSystemService import androidx.core.net.toUri import androidx.core.view.isVisible +import bums.lunatic.launcher.BookmarkUploader import bums.lunatic.launcher.LauncherActivity.Companion.getRuntime import bums.lunatic.launcher.R import bums.lunatic.launcher.model.Dotax @@ -47,6 +51,7 @@ import bums.lunatic.launcher.tokiz.view.BWebview import bums.lunatic.launcher.utils.Blog import bums.lunatic.launcher.utils.CommonUtils import bums.lunatic.launcher.workers.WorkersDb +import com.google.android.material.textfield.TextInputEditText import com.google.gson.Gson import com.vladsch.flexmark.html2md.converter.FlexmarkHtmlConverter import com.yausername.youtubedl_android.YoutubeDL @@ -485,6 +490,124 @@ class GeckoWeb : BWebview { super.onExternalResponse(session, response) } + private fun startBookmarkSaveProcess(pageUrl: String, mediaUrl: String) { + // 1. 로그인 상태 확인 + if (BookmarkUploader.isUserLoggedIn) { + // 이미 로그인 되어 있으면, 바로 북마크 정보 입력창 표시 + showBookmarkDetailsDialog(pageUrl, mediaUrl) + } else { + // 로그인이 안 되어 있으면, 로그인 창을 먼저 표시 + showLoginDialog { + // 로그인 성공 시 콜백으로 북마크 정보 입력창 표시 + showBookmarkDetailsDialog(pageUrl, mediaUrl) + } + } + } + + /** + * 사용자에게 로그인을 요청하는 다이얼로그를 표시하는 함수 + * @param onLoginSuccess 로그인 성공 시 실행될 콜백 함수 + */ + private fun showLoginDialog(onLoginSuccess: () -> Unit) { + val view = LayoutInflater.from(context).inflate(R.layout.dialog_login, null) // dialog_login.xml 레이아웃 필요 + val userIdInput = view.findViewById(R.id.et_user_id) + val userPwInput = view.findViewById(R.id.et_user_pw) + + AlertDialog.Builder(context) + .setTitle("로그인 필요") + .setMessage("북마크를 저장하려면 로그인이 필요합니다.") + .setView(view) + .setPositiveButton("로그인") { dialog, _ -> + val userId = userIdInput.text.toString().trim() + val userPw = userPwInput.text.toString().trim() + + if (userId.isNotEmpty() && userPw.isNotEmpty()) { + // 입력된 정보로 로그인 시도 + BookmarkUploader.loginAndGetToken( + userId = userId, + userPw = userPw, + onSuccess = { + Toast.makeText(context, "로그인 성공!", Toast.LENGTH_SHORT).show() + onLoginSuccess() // 로그인 성공 콜백 실행 + }, + onError = { errorMessage -> + Toast.makeText(context, "로그인 실패: $errorMessage", Toast.LENGTH_LONG).show() + } + ) + } else { + Toast.makeText(context, "아이디와 비밀번호를 모두 입력해주세요.", Toast.LENGTH_SHORT).show() + } + } + .setNegativeButton("취소", null) + .show() + } + + /** + * 북마크 저장을 위해 사용자로부터 코멘트와 공개 여부를 입력받는 다이얼로그 (수정된 버전) + */ + private fun showBookmarkDetailsDialog(pageUrl: String, mediaUrl: String) { + val context = this@GeckoWeb.context ?: return + val visibilityOptions = arrayOf("PUBLIC", "MEMBERS", "PRIVATE") + + // --- UI 요소들을 담을 컨테이너 레이아웃 --- + val containerLayout = LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + val padding = (20 * resources.displayMetrics.density).toInt() // 패딩 값 조정 + setPadding(padding, padding, padding, padding) + } + + // --- 1. 코멘트 입력 EditText --- + val commentInput = EditText(context).apply { + hint = "코멘트를 입력하세요 (선택)" + } + containerLayout.addView(commentInput) // 컨테이너에 추가 + + // --- 2. 공개 범위 선택 RadioGroup --- + val radioGroup = RadioGroup(context).apply { + orientation = RadioGroup.VERTICAL + val marginTop = (16 * resources.displayMetrics.density).toInt() + layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ).apply { + this.topMargin = marginTop + } + } + + // visibilityOptions 배열을 기반으로 라디오 버튼 동적 생성 + visibilityOptions.forEachIndexed { index, option -> + val radioButton = RadioButton(context).apply { + text = option + id = index // 각 라디오 버튼에 고유 ID 부여 + } + radioGroup.addView(radioButton) + } + radioGroup.check(0) // 첫 번째 옵션(PUBLIC)을 기본값으로 선택 + + containerLayout.addView(radioGroup) // 컨테이너에 RadioGroup 추가 + + + // --- 다이얼로그 생성 --- + AlertDialog.Builder(context) + .setTitle("북마크 저장") + .setView(containerLayout) // 직접 만든 레이아웃을 View로 설정 + .setPositiveButton("저장") { _, _ -> + val comment = commentInput.text.toString() + + // 선택된 라디오 버튼의 텍스트를 가져와 visibility로 사용 + val checkedRadioButtonId = radioGroup.checkedRadioButtonId + val visibility = (radioGroup.findViewById(checkedRadioButtonId))?.text.toString() + + if (visibility.isNotEmpty()) { + BookmarkUploader.saveBookmarkWithImageUpload(pageUrl, mediaUrl, comment, visibility) + Toast.makeText(context, "[$visibility] 북마크로 저장했습니다.", Toast.LENGTH_SHORT).show() + } else { + Toast.makeText(context, "공개 범위가 선택되지 않았습니다.", Toast.LENGTH_SHORT).show() + } + } + .setNegativeButton("취소", null) + .show() + } override fun onContextMenu( session: GeckoSession, @@ -493,6 +616,45 @@ class GeckoWeb : BWebview { element: GeckoSession.ContentDelegate.ContextElement ) { + + val pageUrl = element.baseUri ?: lastedUrl ?: return // 페이지 URL + var mediaUrl = element.srcUri ?: return // 이미지 또는 비디오 URL + + // 컨텍스트 메뉴에 '북마크 저장' 옵션 추가 + val menuItems = mutableListOf() + val isMediaElement = element.type == GeckoSession.ContentDelegate.ContextElement.TYPE_IMAGE || + element.type == GeckoSession.ContentDelegate.ContextElement.TYPE_VIDEO + + if (isMediaElement) { + menuItems.add("이 미디어 북마크하기") + menuItems.add("이 미디어 다운로드하기") + } + + if (menuItems.isEmpty()) { + super.onContextMenu(session, screenX, screenY, element) + return + } + fun show(pageUrl : String,mediaUrl : String) { + AlertDialog.Builder(context) + .setTitle("작업 선택") + .setItems(menuItems.toTypedArray()) { _, which -> + when (menuItems[which]) { + "이 미디어 북마크하기" -> { + // 북마크 저장 프로세스 시작 + startBookmarkSaveProcess(pageUrl, mediaUrl) + } + "이 미디어 다운로드하기" -> { + // 기존의 파일 다운로드 로직 실행 + val finalMediaUrl = if (mediaUrl.contains("dcimg")) replaceDcUrl(mediaUrl) else mediaUrl + CommonUtils.downloadFileWithOkHttp(context, Uri.parse(pageUrl), finalMediaUrl) + } + } + } + .show() + } + + + if (element.baseUri?.contains("youtube") == true) { // lastedUrl?.let { videoUrl -> // lastedUrl?.let { @@ -501,20 +663,21 @@ class GeckoWeb : BWebview { // } } else { Blog.LOGE("onContextMenu:: x = ${x}, y = ${y} , element = ${Gson().toJson(element)}") - if (element.type == GeckoSession.ContentDelegate.ContextElement.TYPE_IMAGE) { - element.srcUri?.let { - (if (it.contains("dcimg")){ replaceDcUrl(it) } else { element.srcUri })?.let { - - CommonUtils.downloadFileWithOkHttp(context, Uri.parse(element.baseUri), it) +// if (!BookmarkUploader.isUserLoggedIn) { + BookmarkUploader.loginAndGetToken( + userId = "lunaticbum", + userPw = "VioPup*383", + onSuccess = { + show(pageUrl, mediaUrl) + Toast.makeText(context, "로그인 성공!", Toast.LENGTH_SHORT).show() + }, + onError = { errorMessage -> + Toast.makeText(context, "로그인 실패: $errorMessage", Toast.LENGTH_LONG).show() } - } - }else if (element.type == GeckoSession.ContentDelegate.ContextElement.TYPE_VIDEO) { - element.srcUri?.let { - (if (it.contains("dcimg")){ replaceDcUrl(it) } else { element.srcUri })?.let { - CommonUtils.downloadFileWithOkHttp(context, Uri.parse(element.baseUri), it) - } - } - } + ) +// } else { +//// show(pageUrl, mediaUrl) +// } } super.onContextMenu(session, screenX, screenY, element) } diff --git a/app/src/main/kotlin/bums/lunatic/launcher/utils/CommonUtils.kt b/app/src/main/kotlin/bums/lunatic/launcher/utils/CommonUtils.kt index 8e585d71..54258935 100644 --- a/app/src/main/kotlin/bums/lunatic/launcher/utils/CommonUtils.kt +++ b/app/src/main/kotlin/bums/lunatic/launcher/utils/CommonUtils.kt @@ -190,7 +190,6 @@ object CommonUtils { } Blog.LOGE("downloadFileWithOkHttp File saved: ${resultFile.absolutePath}") - withContext(Dispatchers.Main) { android.widget.Toast.makeText( context, diff --git a/app/src/main/res/layout/dialog_login.xml b/app/src/main/res/layout/dialog_login.xml new file mode 100644 index 00000000..586415c5 --- /dev/null +++ b/app/src/main/res/layout/dialog_login.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test_image.jpg b/test_image.jpg new file mode 100644 index 00000000..fca200bd Binary files /dev/null and b/test_image.jpg differ