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.view.View
import android.widget.CheckBox import android.widget.CheckBox
import android.widget.EditText import android.widget.EditText
import android.widget.LinearLayout
import android.widget.ProgressBar import android.widget.ProgressBar
import android.widget.RadioButton
import android.widget.RadioGroup
import android.widget.TextView import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.core.content.ContextCompat.getSystemService import androidx.core.content.ContextCompat.getSystemService
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.core.view.isVisible import androidx.core.view.isVisible
import bums.lunatic.launcher.BookmarkUploader
import bums.lunatic.launcher.LauncherActivity.Companion.getRuntime import bums.lunatic.launcher.LauncherActivity.Companion.getRuntime
import bums.lunatic.launcher.R import bums.lunatic.launcher.R
import bums.lunatic.launcher.model.Dotax 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.Blog
import bums.lunatic.launcher.utils.CommonUtils import bums.lunatic.launcher.utils.CommonUtils
import bums.lunatic.launcher.workers.WorkersDb import bums.lunatic.launcher.workers.WorkersDb
import com.google.android.material.textfield.TextInputEditText
import com.google.gson.Gson import com.google.gson.Gson
import com.vladsch.flexmark.html2md.converter.FlexmarkHtmlConverter import com.vladsch.flexmark.html2md.converter.FlexmarkHtmlConverter
import com.yausername.youtubedl_android.YoutubeDL import com.yausername.youtubedl_android.YoutubeDL
@ -485,6 +490,124 @@ class GeckoWeb : BWebview {
super.onExternalResponse(session, response) 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( override fun onContextMenu(
session: GeckoSession, session: GeckoSession,
@ -493,6 +616,45 @@ class GeckoWeb : BWebview {
element: GeckoSession.ContentDelegate.ContextElement 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) { if (element.baseUri?.contains("youtube") == true) {
// lastedUrl?.let { videoUrl -> // lastedUrl?.let { videoUrl ->
// lastedUrl?.let { // lastedUrl?.let {
@ -501,20 +663,21 @@ class GeckoWeb : BWebview {
// } // }
} else { } else {
Blog.LOGE("onContextMenu:: x = ${x}, y = ${y} , element = ${Gson().toJson(element)}") Blog.LOGE("onContextMenu:: x = ${x}, y = ${y} , element = ${Gson().toJson(element)}")
if (element.type == GeckoSession.ContentDelegate.ContextElement.TYPE_IMAGE) { // if (!BookmarkUploader.isUserLoggedIn) {
element.srcUri?.let { BookmarkUploader.loginAndGetToken(
(if (it.contains("dcimg")){ replaceDcUrl(it) } else { element.srcUri })?.let { userId = "lunaticbum",
userPw = "VioPup*383",
CommonUtils.downloadFileWithOkHttp(context, Uri.parse(element.baseUri), it) 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) { // } else {
element.srcUri?.let { //// show(pageUrl, mediaUrl)
(if (it.contains("dcimg")){ replaceDcUrl(it) } else { element.srcUri })?.let { // }
CommonUtils.downloadFileWithOkHttp(context, Uri.parse(element.baseUri), it)
}
}
}
} }
super.onContextMenu(session, screenX, screenY, element) super.onContextMenu(session, screenX, screenY, element)
} }

View File

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