This commit is contained in:
lunaticbum 2025-09-19 18:08:03 +09:00
parent 5670ece56d
commit f794e24f24
13 changed files with 1013 additions and 135 deletions

View File

@ -154,8 +154,17 @@ dependencies {
implementation("io.github.junkfood02.youtubedl-android:library:0.17.4")
implementation("io.github.junkfood02.youtubedl-android:ffmpeg:0.17.4")
implementation("io.github.junkfood02.youtubedl-android:aria2c:0.17.4")
implementation("io.coil-kt:coil:2.5.0")
implementation ("com.squareup.okhttp3:logging-interceptor:4.11.0")
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.3")
// Fragment KTX: Fragment에서 by viewModels() 델리게이트를 사용하기 위해 필수
implementation("androidx.fragment:fragment-ktx:1.8.1")
// Lifecycle KTX: viewLifecycleOwner.lifecycleScope 등을 사용하기 위해 필요
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.3")
// implementation ("com.arthenica:ffmpeg-kit-full:4.5.LTS")
// implementation ("com.arthenica:ffmpeg-kit-full:6.0-2")

View File

@ -55,6 +55,16 @@ port.onMessage.addListener(response => {
var type= response["type"];
switch (type) {
case "fetchAllImages": {
const targetSrc = response["targetSrc"];
const imageUrls = findAllRelatedImages(targetSrc);
// 찾은 이미지 URL 목록을 'allImagesFound' 타입으로 네이티브 앱에 다시 전송
sendMessage({
type: "allImagesFound",
urls: imageUrls
});
break;
}
case "search" : {
var keyword = response["keyword"];
if (location.href.search("bigkinds.or.kr") > -1) {
@ -154,6 +164,45 @@ port.onMessage.addListener(response => {
}
});
/**
* [신규 추가] 특정 이미지와 관련된 모든 이미지의 URL을 찾는 함수
* @param {string} targetSrc - 사용자가 길게 누른 이미지의 소스 URL
* @returns {string[]} - 찾아낸 이미지 URL의 배열
*/
function findAllRelatedImages(targetSrc) {
// 1. 페이지 내의 모든 'img' 태그를 찾습니다.
const allImages = Array.from(document.querySelectorAll('img'));
// 2. 사용자가 클릭한 이미지 엘리먼트를 찾습니다.
const targetImage = allImages.find(img => img.src === targetSrc);
if (!targetImage) {
// 클릭한 이미지를 찾지 못하면, 보이는 모든 이미지라도 반환
return allImages.map(img => img.src).filter(src => src && src.startsWith('http'));
}
// 3. 가장 가까운 공통 부모 컨테이너를 찾습니다. (예: article, div.post-body 등)
// 이 로직은 웹사이트의 구조에 따라 고도화될 수 있습니다.
let parent = targetImage.parentElement;
for (let i = 0; i < 5; i++) { // 최대 5단계 위로 탐색
if (parent.querySelectorAll('img').length > 1) {
break; // 다른 이미지를 포함하는 부모를 찾으면 중단
}
if (parent.tagName.toLowerCase() === 'body') break;
parent = parent.parentElement;
}
// 4. 해당 컨테이너 내의 모든 이미지 URL을 수집합니다.
const relatedImages = Array.from(parent.querySelectorAll('img'));
const validUrls = relatedImages
.map(img => img.src)
.filter(src => src && src.startsWith('http')); // 유효한 http(s) URL만 필터링
// 중복 제거 후 반환
return [...new Set(validUrls)];
}
if (document.querySelector(".show_viewer") !== null) {
document.querySelector(".show_viewer").click();
sendMessage({type: "SHOWVIEWER"});

View File

@ -2,10 +2,18 @@ package bums.lunatic.launcher
// ui/bookmark/BookmarkUploader.kt
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import bums.lunatic.launcher.home.adapters.VoteResponse
import bums.lunatic.launcher.home.adapters.WebBookmark
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
@ -233,4 +241,227 @@ object BookmarkUploader {
null
}
}
}
private val saveBookmarkWithContentUrl = "$baseUrl/api/bookmarks/with-content"
// ... loginAndGetToken, saveBookmarkWithOkHttp 함수 ...
/**
* [신규 추가] 여러 이미지와 함께 북마크를 저장하는 함수
* @param pageUrl 원본 페이지의 URL
* @param imageUrls 업로드할 이미지들의 URL 목록
* @param comment 사용자가 작성한 코멘트
* @param visibility 공개 범위 (PUBLIC, MEMBERS, PRIVATE)
*/
fun saveBookmarkWithContent(
pageUrl: String,
imageUrls: List<String>,
comment: String,
visibility: String
) {
if (userJwtToken == null) {
println("🛑 저장을 위해 로그인이 필요합니다.")
return
}
CoroutineScope(Dispatchers.IO).launch {
try {
// --- 1단계: URL 목록의 모든 이미지를 병렬로 다운로드 ---
val downloadedImages = imageUrls.map { imageUrl ->
async { downloadImage(imageUrl, pageUrl) }
}.awaitAll().filterNotNull() // 다운로드 성공한 것만 필터링
if (downloadedImages.isEmpty()) {
println("❌ 모든 이미지 다운로드에 실패했습니다.")
return@launch
}
// --- 2단계: Multipart 요청 생성 ---
val bookmarkDataMap = mapOf(
"url" to pageUrl,
"bookmarkType" to "IMAGE", // 북마크 타입을 IMAGE로 지정
"userComment" to comment,
"visibility" to visibility
)
val bookmarkDataJson = Gson().toJson(bookmarkDataMap)
val multipartBodyBuilder = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("bookmarkData", bookmarkDataJson)
// 다운로드된 각 이미지를 'files'라는 파트 이름으로 추가
downloadedImages.forEachIndexed { index, imageData ->
multipartBodyBuilder.addFormDataPart(
"files", // 서버의 @RequestPart("files")와 일치해야 함
"image_$index.jpg",
imageData.toRequestBody("image/jpeg".toMediaTypeOrNull())
)
}
val multipartBody = multipartBodyBuilder.build()
// --- 3단계: 서버에 업로드 ---
val formattedToken = "Bearer $userJwtToken"
val request = Request.Builder()
.url(saveBookmarkWithContentUrl)
.addHeader("Authorization", formattedToken)
.post(multipartBody)
.build()
OkHttpClientInstance.client.newCall(request).execute().use { response ->
if (response.isSuccessful) {
println("✅ 여러 이미지와 함께 북마크 저장 성공: ${response.body?.string()}")
} else {
println("❌ 여러 이미지와 함께 북마크 저장 실패: ${response.code} ${response.message}")
}
}
} catch (e: Exception) {
println("🔥 북마크 저장(여러 이미지 포함) 중 오류 발생: ${e.message}")
e.printStackTrace()
}
}
}
suspend fun login(userId: String, userPw: String): Boolean = withContext(Dispatchers.IO) {
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) {
println("❌ 로그인 실패: ${response.code} ${response.message}")
return@withContext false
}
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("✅ 로그인 성공! 토큰을 발급받았습니다.")
return@withContext true
}
}
println("❌ 로그인 실패: 응답에 토큰이 없습니다.")
return@withContext false
} catch (e: Exception) {
println("🔥 로그인 중 오류 발생: ${e.message}")
return@withContext false
}
}
fun getJwtToken(): String? = userJwtToken
}
// Spring Page<> 응답과 일치하는 데이터 클래스
data class Page<T>(
val content: List<T>,
val totalElements: Long,
val number: Int,
val size: Int
)
class BookmarkApiService {
private val baseUrl = "https://lunaticbum.kr"
private val bookmarksUrl = "$baseUrl/api/bookmarks/list?page=0&size=20" // 20개씩 가져오도록 설정
private val bookmarkLikeUrl = "$baseUrl/bookmarks/{bookmarkId}/like"
private val bookmarkUnlikeUrl = "$baseUrl/bookmarks/{bookmarkId}/unlike"
/**
* 서버에서 북마크 목록을 가져오는 suspend 함수
* @return 성공 WebBookmark 리스트, 실패 null 반환
*/
suspend fun fetchBookmarks(): List<WebBookmark>? = withContext(Dispatchers.IO) {
val token = BookmarkUploader.getJwtToken() // 인증 토큰 가져오기
if (token == null) {
println("❌ 북마크 로딩 실패: 토큰이 없습니다.")
return@withContext null
}
try {
val request = Request.Builder()
.url(bookmarksUrl)
.addHeader("Authorization", "Bearer $token")
.get()
.build()
val responseJson = OkHttpClientInstance.client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
println("❌ 북마크 API 호출 실패: ${response.code} ${response.message}")
return@withContext null
}
response.body?.string()
}
if (responseJson != null) {
val pageType = object : TypeToken<Page<WebBookmark>>() {}.type
val page: Page<WebBookmark> = Gson().fromJson(responseJson, pageType)
println("✅ 북마크 ${page.content.size}개 로딩 성공!")
return@withContext page.content // Page 객체에서 실제 북마크 목록만 반환
}
return@withContext null
} catch (e: Exception) {
println("🔥 북마크 로딩 중 오류 발생: ${e.message}")
return@withContext null
}
}
suspend fun likeBookmark(bookmarkId: String): VoteResponse? = withContext(Dispatchers.IO) {
val token = BookmarkUploader.getJwtToken() ?: return@withContext null
try {
// POST 요청 시 빈 RequestBody가 필요합니다.
val requestBody = "".toRequestBody("application/json".toMediaTypeOrNull())
val request = Request.Builder()
.url(bookmarkLikeUrl.replace("{bookmarkId}", bookmarkId))
.addHeader("Authorization", "Bearer $token")
.post(requestBody)
.build()
val responseJson = OkHttpClientInstance.client.newCall(request).execute().use { response ->
if (!response.isSuccessful) return@withContext null
response.body?.string()
}
return@withContext Gson().fromJson(responseJson, VoteResponse::class.java)
} catch (e: Exception) {
println("🔥 좋아요 요청 중 오류 발생: ${e.message}")
return@withContext null
}
}
/**
* 특정 북마크에 '싫어요' 요청하는 함수
*/
suspend fun unlikeBookmark(bookmarkId: String): VoteResponse? = withContext(Dispatchers.IO) {
// ... likeBookmark와 거의 동일한 로직, URL만 다름 ...
val token = BookmarkUploader.getJwtToken() ?: return@withContext null
try {
val requestBody = "".toRequestBody("application/json".toMediaTypeOrNull())
val request = Request.Builder()
.url(bookmarkUnlikeUrl.replace("{bookmarkId}", bookmarkId))
.addHeader("Authorization", "Bearer $token")
.post(requestBody)
.build()
val responseJson = OkHttpClientInstance.client.newCall(request).execute().use { response ->
if (!response.isSuccessful) return@withContext null
response.body?.string()
}
return@withContext Gson().fromJson(responseJson, VoteResponse::class.java)
} catch (e: Exception) {
println("🔥 싫어요 요청 중 오류 발생: ${e.message}")
return@withContext null
}
}
}

View File

@ -57,6 +57,8 @@ import bums.lunatic.launcher.helpers.ForeGroundService
import bums.lunatic.launcher.helpers.HeadsetActionButtonReceiver
import bums.lunatic.launcher.home.GeckoWeb
import bums.lunatic.launcher.home.RssHome
import bums.lunatic.launcher.home.adapters.BookmarkDetailFragment
import bums.lunatic.launcher.home.adapters.BookmarkPagerFragment
import bums.lunatic.launcher.model.RssData
import bums.lunatic.launcher.model.RssDataType
import bums.lunatic.launcher.receiver.NLService
@ -506,9 +508,10 @@ open class LauncherActivity : CommonActivity() {
.replace(R.id.fragment_container, Novels())
.commit()
}
R.id.webtoons ->{
supportFragmentManager.beginTransaction()
.replace(R.id.fragment_container, Webtoons())
.replace(R.id.fragment_container, BookmarkPagerFragment())
.commit()
}
R.id.comics ->{

View File

@ -490,124 +490,21 @@ 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 추가
// private fun startBookmarkSaveProcess(pageUrl: String, mediaUrl: String) {
// // 1. 로그인 상태 확인
// if (BookmarkUploader.isUserLoggedIn) {
// // 이미 로그인 되어 있으면, 바로 북마크 정보 입력창 표시
// showBookmarkDetailsDialog(pageUrl, mediaUrl)
// } else {
// // 로그인이 안 되어 있으면, 로그인 창을 먼저 표시
// showLoginDialog {
// // 로그인 성공 시 콜백으로 북마크 정보 입력창 표시
// showBookmarkDetailsDialog(pageUrl, mediaUrl)
// }
// }
// }
// --- 다이얼로그 생성 ---
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,
@ -621,32 +518,41 @@ class GeckoWeb : BWebview {
var mediaUrl = element.srcUri ?: return // 이미지 또는 비디오 URL
// 컨텍스트 메뉴에 '북마크 저장' 옵션 추가
val menuItems = mutableListOf<String>()
val menuItems = listOf("이 미디어만 북마크", "페이지의 모든 이미지 북마크", "다운로드")
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()) {
if (!isMediaElement) {
super.onContextMenu(session, screenX, screenY, element)
return
}
// 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)
"이 미디어 북마크" -> {
// 기존 로직: 단일 이미지 북마크 프로세스 시작
startBookmarkSaveProcessForSingleImage(pageUrl, mediaUrl)
}
"이 미디어 다운로드하기" -> {
// 기존의 파일 다운로드 로직 실행
val finalMediaUrl = if (mediaUrl.contains("dcimg")) replaceDcUrl(mediaUrl) else mediaUrl
CommonUtils.downloadFileWithOkHttp(context, Uri.parse(pageUrl), finalMediaUrl)
"페이지의 모든 이미지 북마크" -> {
// [신규 로직] WebExtension에 모든 이미지 URL 요청
val message = JSONObject()
message.put("type", "fetchAllImages")
message.put("targetSrc", mediaUrl) // 기준이 되는 이미지 URL 전달
mPort?.postMessage(message)
}
"다운로드" -> {
CommonUtils.downloadFileWithOkHttp(context, Uri.parse(pageUrl), mediaUrl)
}
}
}
@ -681,7 +587,138 @@ class GeckoWeb : BWebview {
}
super.onContextMenu(session, screenX, screenY, element)
}
}
/**
* 사용자에게 로그인을 요청하는 다이얼로그를 표시하는 함수
* @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, mediaUrls: List<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.saveBookmarkWithContent(pageUrl, mediaUrls, comment, visibility)
Toast.makeText(context, "[$visibility] 북마크로 ${mediaUrls.size}개 이미지를 저장했습니다.", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(context, "공개 범위가 선택되지 않았습니다.", Toast.LENGTH_SHORT).show()
}
}
.setNegativeButton("취소", null)
.show()
}
private fun startBookmarkSaveProcessForSingleImage(pageUrl: String, mediaUrl: String) {
if (BookmarkUploader.isUserLoggedIn) {
showBookmarkDetailsDialog(pageUrl, listOf(mediaUrl)) // URL을 리스트로 감싸서 전달
} else {
showLoginDialog {
showBookmarkDetailsDialog(pageUrl, listOf(mediaUrl))
}
}
}
// [신규 추가] 여러 이미지 북마크 저장 프로세스를 시작하는 함수
private fun startBookmarkSaveProcessForMultipleImages(pageUrl: String, mediaUrls: List<String>) {
if (BookmarkUploader.isUserLoggedIn) {
showBookmarkDetailsDialog(pageUrl, mediaUrls)
} else {
showLoginDialog {
showBookmarkDetailsDialog(pageUrl, mediaUrls)
}
}
}
// [신규 추가] 단일 이미지 북마크 저장 프로세스를 시작하는 함수
val progressDelegate = object : GeckoSession.ProgressDelegate {
override fun onSecurityChange(
session: GeckoSession,
@ -888,6 +925,11 @@ class GeckoWeb : BWebview {
var lPortMessage =
Gson().fromJson<PortMessage>(message, PortMessage::class.java)
when(lPortMessage.type) {
"allImagesFound" -> {
lastedUrl?.let {
startBookmarkSaveProcessForMultipleImages(it, lPortMessage.urls)
}
}
"SINGLE_IMAGE_DATA"-> {
if (lPortMessage.imgSrc != null && lPortMessage.base64Data != null) {
// *** 핵심: 수신한 Base64 데이터를 커스텀 캐시에 저장 ***

View File

@ -0,0 +1,400 @@
package bums.lunatic.launcher.home.adapters
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.adapter.FragmentStateAdapter
import bums.lunatic.launcher.BookmarkApiService
import bums.lunatic.launcher.BookmarkUploader
import bums.lunatic.launcher.R
import coil.load
import com.google.gson.Gson
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
// bums.lunatic.launcher/ui/BookmarkPagerFragment.kt
import android.widget.Button
import android.widget.EditText
import android.widget.LinearLayout
import android.widget.ProgressBar
import android.widget.Toast
import androidx.core.view.isVisible
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.viewpager2.widget.ViewPager2
import bums.lunatic.launcher.utils.Blog
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
class BookmarkPagerFragment : Fragment() {
// by viewModels() 델리게이트를 사용해 ViewModel 인스턴스 생성
private val viewModel: BookmarkViewModel by viewModels()
private lateinit var viewPager: ViewPager2
private lateinit var loginLayout: LinearLayout
private lateinit var progressBar: ProgressBar
private lateinit var errorTextView: TextView
private lateinit var userIdInput: EditText
private lateinit var userPwInput: EditText
private lateinit var loginButton: Button
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.fragment_bookmark_pager, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// 뷰 초기화
viewPager = view.findViewById(R.id.viewPager)
loginLayout = view.findViewById(R.id.loginLayout)
progressBar = view.findViewById(R.id.progressBar)
errorTextView = view.findViewById(R.id.errorTextView)
userIdInput = view.findViewById(R.id.userIdInput)
userPwInput = view.findViewById(R.id.userPwInput)
loginButton = view.findViewById(R.id.loginButton)
// 로그인 버튼 클릭 리스너 설정
loginButton.setOnClickListener {
val userId = userIdInput.text.toString().trim()
val userPw = userPwInput.text.toString().trim()
if (userId.isNotEmpty() && userPw.isNotEmpty()) {
viewModel.login(userId, userPw)
} else {
Toast.makeText(context, "아이디와 비밀번호를 입력해주세요.", Toast.LENGTH_SHORT).show()
}
}
// ViewModel의 UI 상태를 관찰하고, 상태에 따라 UI 업데이트
viewLifecycleOwner.lifecycleScope.launch {
viewModel.uiState.collect { state ->
updateUi(state)
}
}
}
private fun updateUi(state: UiState) {
// 모든 뷰를 일단 숨김
loginLayout.isVisible = false
viewPager.isVisible = false
progressBar.isVisible = false
errorTextView.isVisible = false
// 상태에 따라 필요한 뷰만 표시
when (state) {
is UiState.LoggedOut -> {
loginLayout.isVisible = true
}
is UiState.Loading -> {
progressBar.isVisible = true
}
is UiState.Success -> {
viewPager.isVisible = true
setupViewPager(state.bookmarks)
}
is UiState.Error -> {
errorTextView.isVisible = true
errorTextView.text = state.message
// 에러 발생 시 잠시 후 다시 로그인 폼을 보여줄 수 있음
if(state.message.contains("아이디")) {
loginLayout.isVisible = true
}
}
}
}
private fun setupViewPager(bookmarks: List<WebBookmark>) {
viewPager.adapter = BookmarkPagerAdapter(this, bookmarks)
}
}
enum class Visibility {
PUBLIC, // 전체 공개
MEMBERS, // 회원 공개
PRIVATE // 비공개 (나만 보기)
}
enum class MetadataStatus {
PENDING, // 처리 대기 중
COMPLETED, // 처리 완료
FAILED // 처리 실패
}
enum class BookmarkType {
URL, // 기존 웹 페이지 링크
IMAGE, // 하나 이상의 이미지
VIDEO // 하나 이상의 비디오
}
data class WebBookmark(
var id: String? = null,
var userId: String, // 누가 저장했는지
var url: String?, // 원본 페이지 URL
// [신규] 북마크 타입 (URL, IMAGE, VIDEO 등)
var bookmarkType: String = BookmarkType.URL.name,
// [신규] 콘텐츠 URL 목록 (웹페이지는 1개, 이미지는 여러 개 가능)
var contentUrls: List<String> = emptyList(),
var title: String? = null, // 페이지 제목
var description: String? = null, // 페이지 요약 (메타 태그)
var thumbnailUrl: String? = null, // 페이지 썸네일 (메타 태그)
var userComment: String? = null, // 사용자가 남긴 짧은 의견
var tags: List<String>? = null, // 태그 (예: #kotlin, #spring)
var savedAt: Long = System.currentTimeMillis(), // 저장 시간
// [신규 추가] 공개 범위 필드. 기본값은 PRIVATE.
var visibility: String = Visibility.PRIVATE.name,
// [신규 추가] 좋아요/싫어요 카운트 필드
var voteCount: Long = 0,
var unlikeCount: Long = 0,
var userSelectedImageUrl: String? = null,
var metadataStatus: String = MetadataStatus.PENDING.name,
// [추가] 카테고리 필드 (하나만 가질 수 있도록 String으로 설정)
var category: String? = null,
val displayImageUrl: String
)
class BookmarkPagerAdapter(fragment: Fragment, private val bookmarks: List<WebBookmark>) : FragmentStateAdapter(fragment) {
override fun getItemCount(): Int = bookmarks.size
override fun createFragment(position: Int): Fragment {
// 각 포지션에 맞는 북마크 데이터를 담아
// BookmarkDetailFragment 인스턴스를 생성하여 반환합니다.
return BookmarkDetailFragment.newInstance(bookmarks[position])
}
}
class BookmarkImageAdapter(private val imageUrls: List<String>) : RecyclerView.Adapter<BookmarkImageAdapter.ImageViewHolder>() {
init {
Blog.LOGE("imageUrls >>> ${imageUrls}")
}
class ImageViewHolder(view: View) : RecyclerView.ViewHolder(view) {
val imageView: ImageView = view.findViewById(R.id.bookmarkImageView)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ImageViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_bookmark_image, parent, false)
return ImageViewHolder(view)
}
override fun onBindViewHolder(holder: ImageViewHolder, position: Int) {
val imageUrl = imageUrls[position]
// Coil을 사용해 URL로부터 이미지를 로드하고 ImageView에 표시합니다.
Blog.LOGE("imageUrl >>> ${imageUrl}")
holder.imageView.load("https://lunaticbum.kr$imageUrl") {
crossfade(true)
placeholder(R.drawable.ic_launcher) // 로딩 중 보여줄 이미지 (ic_placeholder.xml 같은 drawable 필요)
error(R.drawable.ic_news) // 에러 시 보여줄 이미지 (ic_error.xml 같은 drawable 필요)
}
}
override fun getItemCount(): Int = imageUrls.size
}
class BookmarkDetailFragment : Fragment() {
private lateinit var bookmark: WebBookmark
private val viewModel: BookmarkViewModel by activityViewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// newInstance를 통해 전달된 북마크 데이터를 arguments에서 꺼냅니다.
arguments?.getString(ARG_BOOKMARK)?.let {
bookmark = Gson().fromJson(it, WebBookmark::class.java)
}
}
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
// XML 레이아웃을 inflate 합니다.
return inflater.inflate(R.layout.fragment_bookmark_detail, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// View들을 초기화하고 북마크 데이터를 바인딩합니다.
val titleTextView = view.findViewById<TextView>(R.id.bookmarkTitle)
val commentTextView = view.findViewById<TextView>(R.id.bookmarkComment)
val recyclerView = view.findViewById<RecyclerView>(R.id.contentRecyclerView)
titleTextView.text = bookmark.title ?: "제목 없음"
commentTextView.text = bookmark.userComment ?: ""
commentTextView.visibility = if (bookmark.userComment.isNullOrBlank()) View.GONE else View.VISIBLE
val likeButton = view.findViewById<Button>(R.id.likeButton)
val unlikeButton = view.findViewById<Button>(R.id.unlikeButton)
val commentButton = view.findViewById<Button>(R.id.commentButton)
val likeCountText = view.findViewById<TextView>(R.id.likeCountText)
val unlikeCountText = view.findViewById<TextView>(R.id.unlikeCountText)
// 초기 카운트 설정
likeCountText.text = bookmark.voteCount.toString()
unlikeCountText.text = bookmark.unlikeCount.toString()
// 버튼 클릭 리스너 설정
likeButton.setOnClickListener {
bookmark.id?.let { viewModel.likeBookmark(it) }
}
unlikeButton.setOnClickListener {
bookmark.id?.let { viewModel.unlikeBookmark(it) }
}
commentButton.setOnClickListener {
// TODO: 댓글 화면(BottomSheetDialog 또는 새 Fragment)을 띄우는 로직 구현
Toast.makeText(context, "댓글 기능 구현 예정", Toast.LENGTH_SHORT).show()
}
// UI 상태가 변경될 때마다(예: 다른 사용자가 좋아요를 눌렀을 때) 카운트 업데이트
// 이 코드는 ViewPager 스와이프 시에도 현재 북마크의 최신 카운트를 반영해줍니다.
viewLifecycleOwner.lifecycleScope.launch {
viewModel.uiState.collect { state ->
if (state is UiState.Success) {
// 현재 프래그먼트의 북마크 ID와 일치하는 최신 데이터를 찾음
val latestBookmark = state.bookmarks.find { it.id == bookmark.id }
latestBookmark?.let {
likeCountText.text = it.voteCount.toString()
unlikeCountText.text = it.unlikeCount.toString()
}
}
}
}
// RecyclerView 설정
recyclerView.layoutManager = LinearLayoutManager(context)
// contentUrls에 있는 이미지 목록으로 어댑터를 생성하고 RecyclerView에 연결합니다.
recyclerView.adapter = BookmarkImageAdapter(bookmark.contentUrls)
}
companion object {
private const val ARG_BOOKMARK = "bookmark_json"
// Fragment 생성 시 데이터를 안전하게 전달하기 위한 newInstance 패턴
@JvmStatic
fun newInstance(bookmark: WebBookmark) =
BookmarkDetailFragment().apply {
arguments = Bundle().apply {
// 북마크 객체를 JSON 문자열로 변환하여 Bundle에 담습니다.
putString(ARG_BOOKMARK, Gson().toJson(bookmark))
}
}
}
}
// UI 상태를 나타내는 sealed class
sealed class UiState {
object LoggedOut : UiState() // 로그아웃 상태 (로그인 폼 보여줌)
object Loading : UiState() // 로딩 중 (프로그레스바 보여줌)
data class Success(val bookmarks: List<WebBookmark>) : UiState() // 성공 (ViewPager 보여줌)
data class Error(val message: String) : UiState() // 에러 (에러 메시지 보여줌)
}
class BookmarkViewModel : ViewModel() {
private val apiService = BookmarkApiService()
// UI 상태를 관찰할 수 있는 StateFlow
private val _uiState = MutableStateFlow<UiState>(UiState.LoggedOut)
val uiState: StateFlow<UiState> = _uiState
init {
// ViewModel 생성 시 로그인 상태 즉시 확인
checkLoginStatus()
}
private fun checkLoginStatus() {
if (BookmarkUploader.isUserLoggedIn) {
fetchBookmarks() // 이미 로그인 되어있으면 바로 데이터 로딩
} else {
_uiState.value = UiState.LoggedOut // 로그아웃 상태 유지
}
}
fun login(userId: String, userPw: String) {
viewModelScope.launch {
_uiState.value = UiState.Loading // 로딩 상태로 변경
val success = BookmarkUploader.login(userId, userPw)
if (success) {
fetchBookmarks() // 로그인 성공 시 북마크 로딩
} else {
_uiState.value = UiState.Error("아이디 또는 비밀번호를 확인해주세요.")
}
}
}
private fun fetchBookmarks() {
viewModelScope.launch {
_uiState.value = UiState.Loading // 로딩 상태로 변경
val bookmarks = apiService.fetchBookmarks()
if (bookmarks != null) {
if (bookmarks.isEmpty()){
_uiState.value = UiState.Error("저장된 북마크가 없습니다.")
} else {
_uiState.value = UiState.Success(bookmarks) // 성공 상태와 데이터 전달
}
} else {
_uiState.value = UiState.Error("북마크를 불러오는데 실패했습니다.")
}
}
}
fun likeBookmark(bookmarkId: String) {
viewModelScope.launch {
val response = apiService.likeBookmark(bookmarkId)
response?.let {
updateBookmarkCounts(bookmarkId, it.voteCount, it.unlikeCount)
}
}
}
fun unlikeBookmark(bookmarkId: String) {
viewModelScope.launch {
val response = apiService.unlikeBookmark(bookmarkId)
response?.let {
updateBookmarkCounts(bookmarkId, it.voteCount, it.unlikeCount)
}
}
}
/**
* 현재 북마크 리스트에서 특정 북마크의 카운트를 업데이트하고 UI를 갱신하는 함수
*/
private fun updateBookmarkCounts(bookmarkId: String, newVoteCount: Long, newUnlikeCount: Long) {
val currentState = _uiState.value
if (currentState is UiState.Success) {
// 현재 리스트에서 해당 북마크를 찾습니다.
val updatedList = currentState.bookmarks.map { bookmark ->
if (bookmark.id == bookmarkId) {
// 카운트가 변경된 새 객체를 생성하여 교체합니다.
bookmark.copy(voteCount = newVoteCount, unlikeCount = newUnlikeCount)
} else {
bookmark
}
}
// 업데이트된 리스트로 UI 상태를 갱신합니다.
_uiState.value = UiState.Success(updatedList)
}
}
}
data class VoteResponse(
val voteCount: Long,
val unlikeCount: Long
)

View File

@ -15,7 +15,7 @@ class PortMessage {
var privates : ArrayList<RssData>? = null
var currentPage : String? = null
var cookies : String? = null
var urls : List<String> = emptyList()
var imgSrc: String? = null
var base64Data: String? = null
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 309 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 B

View File

@ -0,0 +1,73 @@
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:id="@+id/bookmarkTitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceHeadline5"
tools:text="북마크 제목" />
<TextView
android:id="@+id/bookmarkComment"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textAppearance="?attr/textAppearanceBody1"
tools:text="사용자가 남긴 코멘트입니다." />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/contentRecyclerView"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:layout_marginTop="16dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingVertical="8dp"
android:paddingHorizontal="16dp"
android:gravity="center_vertical"
android:background="?attr/colorSurfaceContainer"> <com.google.android.material.button.MaterialButton
android:id="@+id/likeButton"
style="?attr/materialIconButtonStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:icon="@drawable/ic_thumb_up" /> <TextView
android:id="@+id/likeCountText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
tools:text="12" />
<com.google.android.material.button.MaterialButton
android:id="@+id/unlikeButton"
style="?attr/materialIconButtonStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
app:icon="@drawable/ic_thumb_down" /> <TextView
android:id="@+id/unlikeCountText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
tools:text="3" />
<View
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_weight="1" />
<com.google.android.material.button.MaterialButton
android:id="@+id/commentButton"
style="?attr/materialIconButtonStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:icon="@drawable/ic_comment" /> </LinearLayout>
</LinearLayout>

View File

@ -0,0 +1,63 @@
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/viewPager"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone"
tools:visibility="visible"/>
<LinearLayout
android:id="@+id/loginLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_gravity="center"
android:padding="24dp"
android:visibility="gone"
tools:visibility="gone">
<EditText
android:id="@+id/userIdInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="아이디"
android:inputType="text"/>
<EditText
android:id="@+id/userPwInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:hint="비밀번호"
android:inputType="textPassword"/>
<Button
android:id="@+id/loginButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="로그인"/>
</LinearLayout>
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone"
tools:visibility="gone"/>
<TextView
android:id="@+id/errorTextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone"
tools:text="에러 메시지"
tools:visibility="gone"/>
</FrameLayout>

View File

@ -0,0 +1,8 @@
<ImageView
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/bookmarkImageView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:adjustViewBounds="true"
android:layout_marginBottom="8dp"
android:scaleType="fitCenter" />