This commit is contained in:
lunaticbum 2025-09-18 17:55:44 +09:00
parent c90b660429
commit 5670ece56d
5 changed files with 444 additions and 14 deletions

View File

@ -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<Map<String, String>>() {}.type
val resultMap: Map<String, String> = 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<Map<String, Any>>() {}.type
val resultMap: Map<String, Any> = 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
}
}
}

View File

@ -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<TextInputEditText>(R.id.et_user_id)
val userPwInput = view.findViewById<TextInputEditText>(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<RadioButton>(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<String>()
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)
}

View File

@ -190,7 +190,6 @@ object CommonUtils {
}
Blog.LOGE("downloadFileWithOkHttp File saved: ${resultFile.absolutePath}")
withContext(Dispatchers.Main) {
android.widget.Toast.makeText(
context,

View File

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="24dp">
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="아이디">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/et_user_id"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:hint="비밀번호">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/et_user_pw"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPassword" />
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>

BIN
test_image.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB