diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 288189f3..c491ede6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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") diff --git a/app/src/main/assets/extensions/my_extension/messaging.js b/app/src/main/assets/extensions/my_extension/messaging.js index bb8ff888..8074f7ee 100644 --- a/app/src/main/assets/extensions/my_extension/messaging.js +++ b/app/src/main/assets/extensions/my_extension/messaging.js @@ -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"}); diff --git a/app/src/main/kotlin/bums/lunatic/launcher/BookmarkUploader.kt b/app/src/main/kotlin/bums/lunatic/launcher/BookmarkUploader.kt index cdd05079..2b246c25 100644 --- a/app/src/main/kotlin/bums/lunatic/launcher/BookmarkUploader.kt +++ b/app/src/main/kotlin/bums/lunatic/launcher/BookmarkUploader.kt @@ -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 } } -} \ No newline at end of file + + 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, + 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>() {}.type + val resultMap: Map = 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( + val content: List, + 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? = 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>() {}.type + val page: Page = 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 + } + } + + +} diff --git a/app/src/main/kotlin/bums/lunatic/launcher/LauncherActivity.kt b/app/src/main/kotlin/bums/lunatic/launcher/LauncherActivity.kt index 36b1874a..693d9371 100644 --- a/app/src/main/kotlin/bums/lunatic/launcher/LauncherActivity.kt +++ b/app/src/main/kotlin/bums/lunatic/launcher/LauncherActivity.kt @@ -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 ->{ 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 21b590d6..c1e34152 100644 --- a/app/src/main/kotlin/bums/lunatic/launcher/home/GeckoWeb.kt +++ b/app/src/main/kotlin/bums/lunatic/launcher/home/GeckoWeb.kt @@ -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(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 추가 +// 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(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() + 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(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, mediaUrls: List) { + 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.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) { + 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(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 데이터를 커스텀 캐시에 저장 *** diff --git a/app/src/main/kotlin/bums/lunatic/launcher/home/adapters/BookmarkPagerAdapter.kt b/app/src/main/kotlin/bums/lunatic/launcher/home/adapters/BookmarkPagerAdapter.kt new file mode 100644 index 00000000..49e85cf1 --- /dev/null +++ b/app/src/main/kotlin/bums/lunatic/launcher/home/adapters/BookmarkPagerAdapter.kt @@ -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) { + 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 = emptyList(), + var title: String? = null, // 페이지 제목 + var description: String? = null, // 페이지 요약 (메타 태그) + var thumbnailUrl: String? = null, // 페이지 썸네일 (메타 태그) + var userComment: String? = null, // 사용자가 남긴 짧은 의견 + var tags: List? = 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) : 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) : RecyclerView.Adapter() { +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(R.id.bookmarkTitle) + val commentTextView = view.findViewById(R.id.bookmarkComment) + val recyclerView = view.findViewById(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