This commit is contained in:
lunaticbum 2026-03-19 14:55:22 +09:00
parent 1383781dc6
commit d4ec1d5652
17 changed files with 741 additions and 31 deletions

View File

@ -125,7 +125,15 @@
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" /> </intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
</activity>
<receiver android:name=".receiver.CallReceiver" android:exported="true">
<intent-filter>

View File

@ -2,6 +2,7 @@ package bums.lunatic.launcher
// ui/bookmark/BookmarkUploader.kt
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import bums.lunatic.launcher.home.adapters.VoteResponse
@ -23,6 +24,7 @@ import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.logging.HttpLoggingInterceptor
import java.io.File
object OkHttpClientInstance {
val client: OkHttpClient by lazy {
@ -163,6 +165,7 @@ object BookmarkUploader {
fun saveBookmarkWithImageUpload(
context: Context, // 💡 파라미터 추가
pageUrl: String,
selectedImageUrl: String,
comment: String,
@ -182,6 +185,9 @@ object BookmarkUploader {
return@launch
}
// 💡 다운로드 완료 직후 로컬 폴더에 먼저 저장
saveImageToLocal(context, imageData, selectedImageUrl)
// --- 2단계: Multipart 요청 생성 및 업로드 (기존과 동일) ---
val bookmarkDataMap = mapOf(
"url" to pageUrl,
@ -224,6 +230,28 @@ object BookmarkUploader {
}
}
private fun saveImageToLocal(context: Context, imageData: ByteArray, imageUrl: String) {
try {
val saveDir = File(context.getExternalFilesDir(null), "completed_torrents")
if (!saveDir.exists()) {
saveDir.mkdirs()
}
// URL에서 원본 파일명 추출 시도 (실패 시 타임스탬프로 대체)
var fileName = android.net.Uri.parse(imageUrl).lastPathSegment ?: "img_${System.currentTimeMillis()}.jpg"
// 이름이 중복되어 덮어써지는 것을 막기 위해 타임스탬프를 앞에 붙여줍니다.
fileName = "${System.currentTimeMillis()}_$fileName"
val file = File(saveDir, fileName)
file.writeBytes(imageData)
println("✅ 로컬 보관함 저장 성공: ${file.absolutePath}")
} catch (e: Exception) {
println("🔥 로컬 보관함 저장 실패: ${e.message}")
}
}
/**
* 주어진 URL에서 이미지를 다운로드하여 ByteArray로 반환하는 헬퍼 함수 (Referer 추가)
*/
@ -258,6 +286,7 @@ object BookmarkUploader {
* @param visibility 공개 범위 (PUBLIC, MEMBERS, PRIVATE)
*/
fun saveBookmarkWithContent(
context: Context, // 💡 파라미터 추가
pageUrl: String,
imageUrls: List<String>,
comment: String,
@ -272,14 +301,21 @@ object BookmarkUploader {
try {
// --- 1단계: URL 목록의 모든 이미지를 병렬로 다운로드 ---
val downloadedImages = imageUrls.map { imageUrl ->
async { downloadImage(imageUrl, pageUrl) }
}.awaitAll().filterNotNull() // 다운로드 성공한 것만 필터링
async {
val bytes = downloadImage(imageUrl, pageUrl)
if (bytes != null) Pair(imageUrl, bytes) else null
}
}.awaitAll().filterNotNull()
if (downloadedImages.isEmpty()) {
println("❌ 모든 이미지 다운로드에 실패했습니다.")
return@launch
}
downloadedImages.forEach { (imageUrl, imageData) ->
saveImageToLocal(context, imageData, imageUrl)
}
// --- 2단계: Multipart 요청 생성 ---
val bookmarkDataMap = mapOf(
"url" to pageUrl,
@ -294,9 +330,9 @@ object BookmarkUploader {
.addFormDataPart("bookmarkData", bookmarkDataJson)
// 다운로드된 각 이미지를 'files'라는 파트 이름으로 추가
downloadedImages.forEachIndexed { index, imageData ->
downloadedImages.forEachIndexed { index, (_, imageData) ->
multipartBodyBuilder.addFormDataPart(
"files", // 서버의 @RequestPart("files")와 일치해야 함
"files",
"image_$index.jpg",
imageData.toRequestBody("image/jpeg".toMediaTypeOrNull())
)

View File

@ -18,6 +18,7 @@ import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.provider.OpenableColumns
import android.provider.Settings
import android.view.GestureDetector
import android.view.KeyEvent
@ -69,11 +70,13 @@ import io.realm.kotlin.ext.query
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONObject
import org.mozilla.geckoview.ExperimentDelegate
import org.mozilla.geckoview.GeckoResult
import org.mozilla.geckoview.GeckoRuntime
import org.mozilla.geckoview.GeckoRuntimeSettings
import java.io.File
import java.util.Calendar
import java.util.Date
import kotlin.jvm.java
@ -212,6 +215,57 @@ open class LauncherActivity : CommonActivity() {
} catch (e: Exception) { e.printStackTrace() }
}
private fun handleSharedIntent(intent: Intent) {
if (intent.action == Intent.ACTION_SEND) {
val uri = intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM)
uri?.let { saveToPrivateVault(it) }
} else if (intent.action == Intent.ACTION_SEND_MULTIPLE) {
val uris = intent.getParcelableArrayListExtra<Uri>(Intent.EXTRA_STREAM)
uris?.forEach { saveToPrivateVault(it) }
}
}
// 💡 URI로부터 파일을 읽어서 프라이빗 폴더로 복사하는 함수
private fun saveToPrivateVault(uri: Uri) {
CoroutineScope(Dispatchers.IO).launch {
try {
// 1. 원본 파일명 알아내기
var fileName = "shared_${System.currentTimeMillis()}"
contentResolver.query(uri, null, null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (nameIndex != -1) {
fileName = cursor.getString(nameIndex)
}
}
}
val vaultDir = File(getExternalFilesDir(null), "completed_torrents")
if (!vaultDir.exists()) vaultDir.mkdirs()
// 2. 이름 중복 덮어쓰기 방지를 위해 타임스탬프 추가
val destFile = File(vaultDir, "${System.currentTimeMillis()}_$fileName")
// 3. 스트림 복사 (외부 파일 -> 내 프라이빗 폴더)
contentResolver.openInputStream(uri)?.use { input ->
destFile.outputStream().use { output ->
input.copyTo(output)
}
}
// 4. 완료 후 UI 스레드에서 토스트 띄우기
withContext(Dispatchers.Main) {
showToast("보관함에 저장되었습니다: $fileName")
}
} catch (e: Exception) {
e.printStackTrace()
withContext(Dispatchers.Main) {
showToast("파일을 보관함으로 복사하지 못했습니다.")
}
}
}
}
fun onSwipeLeft() {
showAppDrawer()
}
@ -268,7 +322,11 @@ open class LauncherActivity : CommonActivity() {
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent) // 💡 현재 인텐트를 새로 들어온 인텐트로 갱신!
try {
supportFragmentManager.findFragmentByTag(AppDrawerBottomSheet.TAG)?.let {
try { if (it is AppDrawerBottomSheet) it.dismissAllowingStateLoss() } catch (e : Exception) {}
}
@ -284,13 +342,7 @@ open class LauncherActivity : CommonActivity() {
} else {
intent?.extras?.keySet()?.forEach {
try {
Blog.LOGE(
"onNewIntent :: key >> ${it} :: value >> ${
intent?.extras?.getString(
it
)
}"
)
Blog.LOGE("onNewIntent :: key >> $it :: value >> ${intent.extras?.get(it)}")
} catch (e: Exception) {
e.printStackTrace()
}
@ -300,9 +352,7 @@ open class LauncherActivity : CommonActivity() {
} catch (e : Exception) {
e.printStackTrace()
}
// }
super.onNewIntent(intent)
handleSharedIntent(intent)
}
private fun openWithIntent(intent: Intent){
@ -490,6 +540,8 @@ open class LauncherActivity : CommonActivity() {
android.Manifest.permission.RECEIVE_SMS,
android.Manifest.permission.READ_SMS
))
handleSharedIntent(intent)
}
private var smsReceiver: SmsReceiver? = null

View File

@ -48,7 +48,7 @@ internal class LunaticLauncher : Application() {
appContext?.initGeckoRuntime()
return sRuntime
}
var privateCompletedDir : File? = null
}
private fun initGeckoRuntime() {
@ -89,7 +89,7 @@ prefs:
override fun onCreate() {
super.onCreate()
appContext = this
// Base.initialize(this)
privateCompletedDir = File(getExternalFilesDir(null), "completed_torrents")
PrefHelper.initialize(this)
Base64ImageCache.init(this)
val dir = File("/storage/emulated/0/bums_ob/BUM'S PACED /scraped/logs")

View File

@ -171,10 +171,11 @@ class ForeGroundService : Service() {
try {
val youtubeDLDir = File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
"youtubedl-android"
)
// val youtubeDLDir = File(
// Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
// "youtubedl-android"
// )
val youtubeDLDir = File(getExternalFilesDir(null), "completed_torrents")
val command = YoutubeDLRequest(url)
command.addOption("-o", youtubeDLDir.getAbsolutePath() + "/%(title)s.%(ext)s");
currentProcessId = UUID.randomUUID().toString()

View File

@ -0,0 +1,368 @@
package bums.lunatic.launcher.home
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.webkit.MimeTypeMap
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.ImageView
import android.widget.Spinner
import android.widget.TextView
import android.widget.Toast
import androidx.core.content.FileProvider
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import bums.lunatic.launcher.R
import bums.lunatic.launcher.utils.Blog
import bums.lunatic.launcher.utils.CommonUtils.getFileTypeBySignature
import com.bumptech.glide.Glide
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.File
import java.io.FileOutputStream
// 필터 및 정렬 관리를 위한 Enum
//"전체", "이미지", "영상", "문서", "기타"
enum class FileFilterType(val label : String) { ALL("전체"), IMAGE("이미지"), VIDEO("영상"), DOCUMENT("문서"), OTHER("기타") }
enum class FileSortType(val label : String) { DOWNLOAD_DATE("다운로드일"), LAST_USED("최근 사용"), FREQUENTLY_USED("자주 사용"), SIZE("용량") }
enum class FileViewMode { LIST_TEXT, LIST_THUMB, GRID_LARGE, GRID_SMALL }
//val sortOptions = arrayOf("다운로드일", "최근 사용", "자주 사용", "용량")
class CompletedFilesFragment : Fragment() {
private lateinit var recyclerView: RecyclerView
private lateinit var adapter: CompletedFilesAdapter
// 원본 파일 목록
private var allFiles = listOf<File>()
// 현재 선택된 상태값
private var currentFilter = FileFilterType.ALL
private var currentSort = FileSortType.DOWNLOAD_DATE
private var currentViewMode = FileViewMode.LIST_TEXT // 기본값: 썸네일 리스트
private var isDescending = true // 기본: 내림차순 (최신순/큰용량순)
// 파일 확장자 분류 기준
private val extImages = setOf("jpg", "jpeg", "png", "gif", "bmp", "webp")
private val extVideos = setOf("mp4", "mkv", "avi", "mov", "wmv", "flv", "webm", "ts")
private val extDocs = setOf("pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "hwp")
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.fragment_completed_files, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupRecyclerView(view)
setupControls(view)
loadFiles()
}
private fun setupRecyclerView(view: View) {
recyclerView = view.findViewById(R.id.recyclerViewCompletedFiles)
recyclerView.layoutManager = LinearLayoutManager(requireContext())
adapter = CompletedFilesAdapter(
onItemClick = { file ->
openPrivateFile(requireContext(), file)
},
onItemLongClick = { file ->
android.app.AlertDialog.Builder(context)
.setTitle("파일 삭제")
.setMessage("해당 파일을 삭제하시겠습니까?")
.setPositiveButton("확인") { _, _ ->
CoroutineScope(Dispatchers.IO).launch {
if (file.delete()) {
CoroutineScope(Dispatchers.Main).launch {
Toast.makeText(requireContext(), "삭제되었습니다.", Toast.LENGTH_SHORT).show()
loadFiles()
}
}
}
}
.setNegativeButton("취소", null)
.show()
}
)
recyclerView.adapter = adapter
}
private fun getViewModeSymbolName(mode: FileViewMode): String {
return when (mode) {
FileViewMode.LIST_TEXT -> "view_headline" // 텍스트 리스트
FileViewMode.LIST_THUMB -> "view_list" // 썸네일 리스트
FileViewMode.GRID_LARGE -> "grid_view" // 큰 그리드
FileViewMode.GRID_SMALL -> "apps" // 작은 그리드 (view_comfy 도 가능)
}
}
private fun setupControls(view: View) {
val spinnerFilter = view.findViewById<Spinner>(R.id.spinnerFilter)
val spinnerSort = view.findViewById<Spinner>(R.id.spinnerSort)
val tvSortOrder = view.findViewById<TextView>(R.id.tvSortOrder)
val btnViewMode = view.findViewById<TextView>(R.id.btnViewMode)
// 초기 뷰 모드에 맞는 이모지 세팅
btnViewMode.text = getViewModeSymbolName(currentViewMode)
adapter.setViewMode(currentViewMode)
btnViewMode.setOnClickListener {
currentViewMode = when (currentViewMode) {
FileViewMode.LIST_TEXT -> FileViewMode.LIST_THUMB
FileViewMode.LIST_THUMB -> FileViewMode.GRID_LARGE
FileViewMode.GRID_LARGE -> FileViewMode.GRID_SMALL
FileViewMode.GRID_SMALL -> FileViewMode.LIST_TEXT
}
btnViewMode.text = getViewModeSymbolName(currentViewMode)
updateRecyclerViewLayoutManager()
// 모드 변경을 어댑터에 알림
adapter.setViewMode(currentViewMode)
}
// 1. 필터 스피너 설정
val filterOptions = FileFilterType.values().map { it.label }
spinnerFilter.adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_dropdown_item, filterOptions)
spinnerFilter.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(p0: AdapterView<*>?, p1: View?, position: Int, p3: Long) {
currentFilter = FileFilterType.values()[position]
applyFilterAndSort()
}
override fun onNothingSelected(p0: AdapterView<*>?) {}
}
// 2. 정렬 스피너 설정
val sortOptions = FileSortType.values().map { it.label }
spinnerSort.adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_dropdown_item, sortOptions)
spinnerSort.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(p0: AdapterView<*>?, p1: View?, position: Int, p3: Long) {
currentSort = FileSortType.values()[position]
applyFilterAndSort()
}
override fun onNothingSelected(p0: AdapterView<*>?) {}
}
// 3. 정순/역순 토글 버튼 설정
tvSortOrder.text = if (isDescending) "arrow_downward" else "arrow_upward"
tvSortOrder.setOnClickListener {
isDescending = !isDescending
// 내림차순이면 아래 화살표, 오름차순이면 위 화살표
tvSortOrder.text = if (isDescending) "arrow_downward" else "arrow_upward"
applyFilterAndSort()
}
}
// 파일 목록 불러오기
private fun loadFiles() {
val completedDir = File(requireContext().getExternalFilesDir(null), "completed_torrents")
if (completedDir.exists()) {
allFiles = completedDir.walkTopDown().filter { it.isFile }.toList()
} else {
allFiles = emptyList()
}
applyFilterAndSort()
}
override fun onHiddenChanged(hidden: Boolean) {
super.onHiddenChanged(hidden)
loadFiles()
}
// 💡 핵심 로직: 필터 및 정렬 적용
private fun applyFilterAndSort() {
// 1. 필터링
var processedList = allFiles.filter { file ->
val ext = file.extension.lowercase()
when (currentFilter) {
FileFilterType.ALL -> true
FileFilterType.IMAGE -> extImages.contains(ext)
FileFilterType.VIDEO -> extVideos.contains(ext)
FileFilterType.DOCUMENT -> extDocs.contains(ext)
FileFilterType.OTHER -> !extImages.contains(ext) && !extVideos.contains(ext) && !extDocs.contains(ext)
}
}
// 2. 정렬
processedList = when (currentSort) {
FileSortType.DOWNLOAD_DATE -> {
if (isDescending) processedList.sortedByDescending { it.lastModified() }
else processedList.sortedBy { it.lastModified() }
}
FileSortType.LAST_USED -> {
if (isDescending) processedList.sortedByDescending { getLastAccessedTime(it.name) }
else processedList.sortedBy { getLastAccessedTime(it.name) }
}
FileSortType.SIZE -> {
if (isDescending) processedList.sortedByDescending { it.length() }
else processedList.sortedBy { it.length() }
}
FileSortType.FREQUENTLY_USED -> {
if (isDescending) processedList.sortedByDescending { getAccessCount(it.name) }
else processedList.sortedBy { getAccessCount(it.name) }
}
}
adapter.submitList(processedList)
}
private fun trackFileAccess(fileName: String) {
val prefs = requireContext().getSharedPreferences("FileAccessTracker", Context.MODE_PRIVATE)
// 기존 횟수를 불러와서 1을 더함 (처음 열면 0 + 1)
val currentCount = prefs.getInt("${fileName}_count", 0)
prefs.edit()
.putLong(fileName, System.currentTimeMillis()) // 기존 시간 기록 유지
.putInt("${fileName}_count", currentCount + 1) // 횟수 기록 추가
.apply()
}
// '최근 사용 시간' 불러오기 (기존 함수 유지)
private fun getLastAccessedTime(fileName: String): Long {
val prefs = requireContext().getSharedPreferences("FileAccessTracker", Context.MODE_PRIVATE)
return prefs.getLong(fileName, 0L)
}
// 💡 '접근 빈도(횟수)' 불러오기 (신규)
private fun getAccessCount(fileName: String): Int {
val prefs = requireContext().getSharedPreferences("FileAccessTracker", Context.MODE_PRIVATE)
return prefs.getInt("${fileName}_count", 0)
}
// 💡 확장자에 맞춰 동적 MIME 타입 생성
private fun getMimeType(file: File): String {
val extension = MimeTypeMap.getFileExtensionFromUrl(Uri.fromFile(file).toString()) ?: file.extension
return MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension.lowercase()) ?: "*/*"
}
// 외부 플레이어/뷰어로 파일 열기
private fun openPrivateFile(context: Context, file: File) {
try {
// 파일을 열었으므로 최근 사용 시간 갱신
trackFileAccess(file.name)
val uri: Uri = FileProvider.getUriForFile(
context,
"bums.lunatic.launcher.fileprovider",
file
)
val mimeType = getMimeType(file)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, mimeType)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(intent)
// 열고 나서 '최근 사용' 순서가 갱신되어야 하므로 리스트 다시 정렬
if (currentSort == FileSortType.LAST_USED) {
applyFilterAndSort()
}
} catch (e: IllegalArgumentException) {
e.printStackTrace()
Toast.makeText(context, "파일을 공유할 수 없습니다.", Toast.LENGTH_SHORT).show()
} catch (e: ActivityNotFoundException) {
Toast.makeText(context, "이 파일을 열 수 있는 앱이 설치되어 있지 않습니다.", Toast.LENGTH_SHORT).show()
}
}
private fun updateRecyclerViewLayoutManager() {
recyclerView.layoutManager = when (currentViewMode) {
FileViewMode.LIST_TEXT, FileViewMode.LIST_THUMB ->
LinearLayoutManager(requireContext())
FileViewMode.GRID_LARGE ->
GridLayoutManager(requireContext(), 2) // 3열 그리드
FileViewMode.GRID_SMALL ->
GridLayoutManager(requireContext(), 4) // 5열 그리드
}
}
}
// --- RecyclerView 어댑터 ---
class CompletedFilesAdapter(
private val onItemClick: (File) -> Unit,
private val onItemLongClick: (File) -> Unit
) : RecyclerView.Adapter<CompletedFilesAdapter.FileViewHolder>() {
private var fileList: List<File> = emptyList()
private var viewMode: FileViewMode = FileViewMode.LIST_THUMB
fun submitList(files: List<File>) {
fileList = files
notifyDataSetChanged()
}
fun setViewMode(mode: FileViewMode) {
viewMode = mode
notifyDataSetChanged() // 모드가 바뀌면 전체 리스트를 다시 그립니다.
}
// 💡 모드에 따라 ViewType을 다르게 반환
override fun getItemViewType(position: Int): Int {
return viewMode.ordinal
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FileViewHolder {
val layoutRes = when (FileViewMode.values()[viewType]) {
FileViewMode.LIST_TEXT -> R.layout.item_file_list_text
FileViewMode.LIST_THUMB -> R.layout.item_file_list_thumb
FileViewMode.GRID_LARGE, FileViewMode.GRID_SMALL -> R.layout.item_file_grid
}
val view = LayoutInflater.from(parent.context).inflate(layoutRes, parent, false)
return FileViewHolder(view)
}
override fun onBindViewHolder(holder: FileViewHolder, position: Int) {
holder.bind(fileList[position], viewMode)
}
override fun getItemCount(): Int = fileList.size
inner class FileViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
// 레이아웃에 따라 특정 뷰가 null일 수 있으므로 nullable(?)로 선언
private val tvFileName: TextView? = itemView.findViewById(R.id.tvFileName)
private val tvFileSize: TextView? = itemView.findViewById(R.id.tvFileSize)
private val ivThumb: ImageView? = itemView.findViewById(R.id.ivThumb)
fun bind(file: File, mode: FileViewMode) {
tvFileName?.text = file.name
val sizeMb = file.length() / (1024.0 * 1024.0)
tvFileSize?.text = String.format("%.2f MB • %s", sizeMb, file.extension.uppercase())
// 💡 썸네일 뷰가 존재하는 모드일 경우 Glide로 이미지 로딩
if (ivThumb != null) {
// 이미지나 영상 파일인 경우 썸네일 표시, 아니면 기본 아이콘 등 처리 가능
Glide.with(itemView.context)
.load(file)
.placeholder(android.R.drawable.ic_menu_report_image) // 로딩 중 기본 이미지
.into(ivThumb)
}
itemView.setOnClickListener { onItemClick(file) }
itemView.setOnLongClickListener {
onItemLongClick(file)
true
}
}
}
}

View File

@ -674,7 +674,7 @@ open class GeckoWeb @JvmOverloads constructor(
AlertDialog.Builder(context).setTitle("북마크 저장").setView(container)
.setPositiveButton("저장") { _, _ ->
val vis = rg.findViewById<RadioButton>(rg.checkedRadioButtonId).text.toString()
BookmarkUploader.saveBookmarkWithContent(pageUrl, mediaUrls, commentInput.text.toString(), vis)
BookmarkUploader.saveBookmarkWithContent(context,pageUrl, mediaUrls, commentInput.text.toString(), vis)
}
.setNegativeButton("취소", null).show()
}

View File

@ -61,13 +61,6 @@ import io.realm.kotlin.ext.query
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.json.JSONObject
import org.mozilla.geckoview.ExperimentDelegate
import org.mozilla.geckoview.GeckoResult
import org.mozilla.geckoview.GeckoRuntime
import org.mozilla.geckoview.GeckoRuntimeSettings
import org.mozilla.geckoview.GeckoRuntimeSettings.ALLOW_ALL
import java.io.File
import kotlin.math.abs
interface KeyEventHandler {
@ -537,6 +530,7 @@ open class NeoRssActivity : CommonActivity() {
R.id.btn_i -> TokiFragment.newInstanceI()
R.id.btn_torrent -> TorrentListFragment()
R.id.btn_info -> SystemStatusFragment()
R.id.btn_completed_files -> CompletedFilesFragment()
R.id.close -> {
finish()
return

View File

@ -123,7 +123,7 @@ object CommonUtils {
val request = Request.Builder().url(fileUrl).addHeader("Referer", refferer.toString())
.addHeader("User-Agent", "Mozilla/5.0").build()
val dir = File("/storage/emulated/0/bums_ob/BUM'S PACED /scraped/md")
val dir = File(context.getExternalFilesDir(null), "completed_torrents")
if (!dir.exists()) {
dir.mkdirs()
} else {

View File

@ -21,6 +21,10 @@ import com.frostwire.jlibtorrent.alerts.*
import android.app.*
import android.content.*
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.os.*
import androidx.annotation.RequiresApi
import bums.lunatic.launcher.home.toast
@ -49,6 +53,59 @@ data class TorrentTask(
)
class TorrentService : Service() {
private lateinit var connectivityManager: ConnectivityManager
private var networkCallback: ConnectivityManager.NetworkCallback? = null
private var isWifiConnected = false
private fun registerNetworkCallback() {
connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
networkCallback = object : ConnectivityManager.NetworkCallback() {
override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) {
super.onCapabilitiesChanged(network, networkCapabilities)
val isWifi = networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
if (isWifi) {
if (!isWifiConnected) {
isWifiConnected = true
println("TorrentService: Wi-Fi 연결됨. 전체 다운로드 세션 재개.")
session.resume() // 💡 세션 전체 트래픽 재개
}
} else {
if (isWifiConnected) {
isWifiConnected = false
println("TorrentService: Wi-Fi 연결 아님(셀룰러 등). 전체 다운로드 세션 일시정지.")
session.pause() // 💡 세션 전체 트래픽 차단
}
}
}
override fun onLost(network: Network) {
super.onLost(network)
if (isWifiConnected) {
isWifiConnected = false
println("TorrentService: 네트워크 완전히 끊김. 전체 다운로드 세션 일시정지.")
session.pause()
}
}
}
// 초기 상태 체크 (서비스 시작 시점이 Wi-Fi 환경인지 확인)
val activeNetwork = connectivityManager.activeNetwork
val caps = connectivityManager.getNetworkCapabilities(activeNetwork)
isWifiConnected = caps?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true
// 처음 켰을 때 Wi-Fi가 아니면 바로 일시정지
if (!isWifiConnected) {
println("TorrentService: 현재 Wi-Fi가 아닙니다. 세션을 일시정지 상태로 시작합니다.")
session.pause()
}
connectivityManager.registerNetworkCallback(request, networkCallback!!)
}
private lateinit var session: SessionManager
private val binder = TorrentBinder()
@ -75,6 +132,7 @@ class TorrentService : Service() {
super.onCreate()
startForegroundService()
initLibTorrent()
registerNetworkCallback()
startPolling() // 폴링 시작
}
@ -189,6 +247,7 @@ class TorrentService : Service() {
override fun onDestroy() {
super.onDestroy()
networkCallback?.let { connectivityManager.unregisterNetworkCallback(it) }
serviceScope.cancel() // 서비스 종료 시 코루틴 정리
}
@ -273,7 +332,8 @@ class TorrentService : Service() {
when (alert.type()) {
AlertType.TORRENT_FINISHED -> {
val ta = alert as TorrentFinishedAlert
moveToPublicDownload(ta.handle())
// moveToPublicDownload(ta.handle())
moveToPrivateStorage(ta.handle())
}
AlertType.METADATA_RECEIVED -> {
val ma = alert as MetadataReceivedAlert
@ -359,6 +419,69 @@ class TorrentService : Service() {
}
}
@RequiresApi(Build.VERSION_CODES.Q)
private fun moveToPrivateStorage(handle: TorrentHandle) {
try {
val status = handle.status()
val infoHash = status.infoHash().toString()
var torrentName = status.name()
if (status.hasMetadata()) {
val torrentInfo = handle.torrentFile()
if (torrentInfo != null && torrentInfo.isValid) {
val realName = torrentInfo.name()
if (!realName.isNullOrEmpty()) {
torrentName = realName
}
}
}
if (torrentName.isNullOrEmpty() || torrentName == infoHash) {
println("TorrentService: 이동할 유효한 파일/폴더 이름을 찾지 못했습니다.")
return
}
val sourcePath = File(tempDir, torrentName)
if (!sourcePath.exists()) {
println("TorrentService: 원본 파일/폴더가 존재하지 않습니다 -> ${sourcePath.absolutePath}")
return
}
// 💡 앱 전용 프라이빗 폴더 설정 (다른 앱 접근 불가)
// 안드로이드/data/bums.lunatic.launcher/files/completed_torrents 에 저장됩니다.
val privateCompletedDir = File(getExternalFilesDir(null), "completed_torrents")
if (!privateCompletedDir.exists()) {
privateCompletedDir.mkdirs()
}
val destPath = File(privateCompletedDir, torrentName)
// 1. renameTo로 빠른 파일/폴더 이동 시도 (같은 파티션 내 이동 시 즉시 완료됨)
val success = sourcePath.renameTo(destPath)
// 2. 만약 renameTo가 실패할 경우 (파티션이 다르거나 권한 문제 등), 직접 복사 후 삭제
if (!success) {
sourcePath.copyRecursively(destPath, overwrite = true)
sourcePath.deleteRecursively()
}
// 세션에서 제거 및 리쥼 파일 삭제
session.remove(handle)
val resumeFile = File(resumeDir, "$infoHash.resume")
if (resumeFile.exists()) {
resumeFile.delete()
println("TorrentService: 리쥼 파일 삭제 완료 ($infoHash.resume)")
}
println("TorrentService: 다운로드 완료 및 프라이빗 폴더 이동 성공 ($torrentName)")
} catch (e: Exception) {
e.printStackTrace()
println("TorrentService: 파일 이동 중 에러 발생 - ${e.message}")
}
}
@RequiresApi(Build.VERSION_CODES.Q)
private fun moveToPublicDownload(handle: TorrentHandle) {
try {

View File

@ -0,0 +1,62 @@
<?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="match_parent"
android:orientation="vertical"
android:background="?android:attr/windowBackground">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="다운로드 보관함"
android:textSize="20sp"
android:textStyle="bold"
android:padding="16dp"/>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingHorizontal="16dp"
android:paddingBottom="8dp"
android:gravity="center_vertical">
<TextView
android:id="@+id/btnViewMode"
style="@style/MaterialIconButtonStyle"
android:layout_width="48dp"
android:layout_height="48dp"
android:text="view_headline" />
<Space
android:layout_weight="1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>
<Spinner
android:id="@+id/spinnerFilter"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1" />
<Spinner
android:id="@+id/spinnerSort"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1" />
<TextView
android:id="@+id/tvSortOrder"
style="@style/MaterialIconButtonStyle"
android:layout_width="48dp"
android:layout_height="48dp"
android:text="arrow_downward" />
</LinearLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerViewCompletedFiles"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="8dp"/>
</LinearLayout>

View File

@ -0,0 +1,26 @@
<?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="12dp"
android:layout_marginBottom="4dp"
android:background="?android:attr/selectableItemBackground">
<TextView
android:id="@+id/tvFileName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="16sp"
android:textColor="@android:color/black"
android:singleLine="true"
android:ellipsize="middle"/>
<TextView
android:id="@+id/tvFileSize"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="12sp"
android:textColor="@android:color/darker_gray"
android:layout_marginTop="4dp"/>
</LinearLayout>

View File

@ -0,0 +1,10 @@
<?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="4dp"
android:background="?attr/selectableItemBackground">
<ImageView android:id="@+id/ivThumb" android:layout_width="match_parent" android:layout_height="100dp" android:scaleType="centerCrop" android:background="#E0E0E0"/>
<TextView android:id="@+id/tvFileName" android:layout_width="match_parent" android:layout_height="wrap_content" android:textSize="12sp" android:textColor="@android:color/white" android:singleLine="true" android:ellipsize="end" android:gravity="center" android:layout_marginTop="4dp"/>
</LinearLayout>

View File

@ -0,0 +1,10 @@
<?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="12dp"
android:background="?attr/selectableItemBackground">
<TextView android:id="@+id/tvFileName" android:layout_width="match_parent" android:layout_height="wrap_content" android:textSize="16sp" android:textColor="@android:color/white" android:singleLine="true" android:ellipsize="middle"/>
<TextView android:id="@+id/tvFileSize" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="12sp" android:textColor="@android:color/darker_gray" android:layout_marginTop="4dp"/>
</LinearLayout>

View File

@ -0,0 +1,14 @@
<?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="horizontal"
android:padding="8dp"
android:background="?attr/selectableItemBackground"
android:gravity="center_vertical">
<ImageView android:id="@+id/ivThumb" android:layout_width="60dp" android:layout_height="60dp" android:scaleType="centerCrop" android:background="#E0E0E0"/>
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:layout_marginStart="12dp">
<TextView android:id="@+id/tvFileName" android:layout_width="match_parent" android:layout_height="wrap_content" android:textSize="16sp" android:textColor="@android:color/white" android:singleLine="true" android:ellipsize="middle"/>
<TextView android:id="@+id/tvFileSize" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="12sp" android:textColor="@android:color/darker_gray" android:layout_marginTop="4dp"/>
</LinearLayout>
</LinearLayout>

View File

@ -83,6 +83,11 @@
style="@style/CommonFabStyle"
android:id="@+id/btn_torrent"
/>
<bums.lunatic.launcher.view.FloatingActionButton
app:fab_label="📦"
style="@style/CommonFabStyle"
android:id="@+id/btn_completed_files"
/>
<bums.lunatic.launcher.view.FloatingActionButton
app:fab_label="📊"
style="@style/CommonFabStyle"

View File

@ -1,4 +1,5 @@
<paths>
<external-path name="apk_files" path="." />
<external-path name="external_files" path="." />
<external-files-path name="completed_videos" path="completed_torrents" />
</paths>