This commit is contained in:
lunaticbum 2026-03-04 16:39:49 +09:00
parent b73fbf2160
commit 614e244be7
21 changed files with 1597 additions and 258 deletions

View File

@ -359,7 +359,10 @@ function toast(msg) {
port.postMessage(JSON.stringify({type:"MSG",msg:msg}));
}
var mainContentsEl = null
var mainContentsEl = null;
let lastState = null; // 이전 상태 저장 (-1, 0, 1)
let throttleTimer = null;
document.addEventListener('DOMContentLoaded', function () {
const currentUrl = location.href;
@ -382,6 +385,40 @@ document.addEventListener('DOMContentLoaded', function () {
// toast("connect port on " + location.href);
time1 = setTimeout(autoScrollAndSave(false), 3500);
}
window.onscroll = function() {
if (throttleTimer) return; // 이미 타이머가 동작 중이면 무시
throttleTimer = setTimeout(() => {
const scrollY = window.scrollY;
const innerHeight = window.innerHeight;
const offsetHeight = document.body.offsetHeight;
let currentState;
if (scrollY <= 2) {
currentState = -1; // 최상단
} else if ((innerHeight + scrollY) >= offsetHeight - 10) {
currentState = 1; // 최하단
} else {
currentState = 0; // 중간
}
// 💡 상태가 바뀌었을 때만 앱으로 전송
if (currentState !== lastState) {
lastState = currentState;
sendMessage({
type: "SCROLL_STATE",
value: currentState
});
}
throttleTimer = null;
}, 150); // 0.15초 간격으로 체크 (사용성에 따라 조절 가능)
};
})
const keywords = ["youtube", "mojeek"];

View File

@ -218,8 +218,10 @@ open class LauncherActivity : CommonActivity() {
fun onSwipeRight() {
startActivity(Intent(this, NeoRssActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
// 이미 실행 중인 액티비티가 있다면 스택의 맨 위로 올리고 재사용합니다.
addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT)
// 기존에 있던 SINGLE_TOP과 함께 사용하면 중복 생성을 더 확실히 방지합니다.
addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
})
}
@ -267,10 +269,14 @@ open class LauncherActivity : CommonActivity() {
override fun onNewIntent(intent: Intent) {
try {
supportFragmentManager.findFragmentByTag(AppDrawerBottomSheet.TAG)?.let {
try { if (it is AppDrawerBottomSheet) it.dismissAllowingStateLoss() } catch (e : Exception) {}
}
val fragment = supportFragmentManager.findFragmentByTag(AppDrawerBottomSheet.TAG)
Blog.LOGE("fragment >>> $fragment")
if (fragment is AppDrawerBottomSheet) {
fragment.dismiss()
try { fragment.dismiss() } catch (e : Exception) {}
}
if (intent?.action?.equals(Intent.ACTION_MAIN) == true && intent.categories.contains(
Intent.CATEGORY_HOME)) {
@ -357,9 +363,17 @@ open class LauncherActivity : CommonActivity() {
fun showAppDrawer() {
// 이미 화면에 떠 있는지 확인
val existingFragment = supportFragmentManager.findFragmentByTag(AppDrawerBottomSheet.TAG)
if (existingFragment != null && existingFragment.isAdded) {
return // 이미 존재하면 새로 띄우지 않음
}
val bottomSheet = AppDrawerBottomSheet.newInstance()
bottomSheet.show(supportFragmentManager, AppDrawerBottomSheet.TAG)
}
private lateinit var homeGestureDetector: androidx.core.view.GestureDetectorCompat
@SuppressLint("NewApi", "MissingPermission", "ClickableViewAccessibility")

View File

@ -3,6 +3,7 @@ package bums.lunatic.launcher.apps
import android.app.Dialog
import android.app.SearchManager
import android.content.Intent
import android.graphics.Color
import android.net.Uri
import android.os.Bundle
import android.view.KeyEvent
@ -129,11 +130,23 @@ class AppDrawerBottomSheet : BottomSheetDialogFragment() {
val displayList = categoryMap.keys.toList()
// 2. 어댑터 설정 (기본 안드로이드 레이아웃 사용)
val adapter = ArrayAdapter(
val adapter = object : ArrayAdapter<String>(
requireContext(),
R.layout.spinner_item_dark,
displayList
)
){
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val view = super.getView(position, convertView, parent)
view.setBackgroundColor(Color.parseColor("#000000")) // 펼쳐진 리스트의 배경
return view
}
override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View {
val view = super.getDropDownView(position, convertView, parent)
view.setBackgroundColor(Color.parseColor("#000000")) // 펼쳐진 리스트의 배경
return view
}
}
adapter.setDropDownViewResource(R.layout.spinner_item_dark)
binding.categorySpinner.adapter = adapter
@ -147,6 +160,8 @@ class AppDrawerBottomSheet : BottomSheetDialogFragment() {
fetchApps(binding.searchInput.text.toString())
}
override fun onNothingSelected(parent: AdapterView<*>?) {}
}
}

View File

@ -34,6 +34,7 @@ import bums.lunatic.launcher.utils.Blog
import bums.lunatic.launcher.workers.TaskAggregator
import com.yausername.youtubedl_android.YoutubeDL
import com.yausername.youtubedl_android.YoutubeDLRequest
import io.realm.kotlin.internal.interop.sync.CancellableTimer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@ -45,7 +46,11 @@ import okhttp3.ResponseBody
import java.io.File
import java.util.UUID
import java.util.concurrent.TimeUnit
import android.bluetooth.BluetoothClass
import android.media.AudioManager
import android.os.SystemClock
import android.view.KeyEvent
import kotlinx.coroutines.delay
class AggregatedSystemWorker(private val context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
@ -63,6 +68,19 @@ class AggregatedNewsWorker(private val context: Context, params: WorkerParameter
}
}
class VibrationWorker(private val context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val vibrator = context.getSystemService(Context.VIBRATOR_SERVICE) as android.os.Vibrator
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
vibrator.vibrate(android.os.VibrationEffect.createOneShot(1000, android.os.VibrationEffect.DEFAULT_AMPLITUDE))
} else {
vibrator.vibrate(1000)
}
// 필요 시 여기서 상단 알림(Notification)을 한 번 더 띄워줄 수 있습니다.
return Result.success()
}
}
class ForeGroundService : Service() {
companion object {
val ACTION_SENDMSG = "ACTION_SEND_TO_LOVE"
@ -70,6 +88,7 @@ class ForeGroundService : Service() {
val ACTION_VIDEO_DOWNLOAD = "ACTION_YTURL_DOWNLOAD"
val EXTRA_TARGET_URL = "ACTION_SEND_TO_LOVE"
val ACTION_SIT_DOWN = "ACTION_SIT_DOWN"
val targetUrls = arrayListOf<String>()
}
@ -89,9 +108,11 @@ class ForeGroundService : Service() {
val filter = IntentFilter(BluetoothDevice.ACTION_ACL_CONNECTED)
registerReceiver(bluetoothreceiver, filter)
refreshFeeds()
startForeGround()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Blog.LOGE("intent?.action >> ${intent?.action}")
when(intent?.action) {
ACTION_SENDMSG -> {
intent?.getStringExtra(EXTRA_MSGKEY)?.let {
@ -105,9 +126,27 @@ class ForeGroundService : Service() {
}
}
}
}
ACTION_SIT_DOWN -> {
val sitDownRequest = OneTimeWorkRequestBuilder<VibrationWorker>()
.setInitialDelay(45, TimeUnit.MINUTES) // 45분 지연
.build()
WorkManager.getInstance(this).enqueueUniqueWork(
"SitDownVibration",
ExistingWorkPolicy.REPLACE, // 새로 누를 때마다 타이머 초기화 (원치 않으면 KEEP)
sitDownRequest
)
// 사용자 확인을 위해 알림 텍스트 변경 가능
startForeGround(0, 0, "45분 뒤에 진동 알림이 예약되었습니다.")
}
else -> {
}
}.apply {
if(intent?.action?.equals(ACTION_SIT_DOWN) == false) {
startForeGround()
}
}
return START_STICKY
}
@ -161,7 +200,8 @@ class ForeGroundService : Service() {
}
}
fun startForeGround(max : Int = 0 , progress : Int = 0, str : String = "실행중입니다.") {
fun startForeGround(max : Int = 0 , progress : Int = 0, str : String? = "실행중입니다.") {
Blog.LOGE(str?:"")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
@ -189,11 +229,20 @@ class ForeGroundService : Service() {
.setSmallIcon(R.drawable.ic_b)
.setContentIntent(pendingIntent)
.addAction(android.R.drawable.ic_btn_speak_now,"퇴근", makeSendMsgAction(0,"돼지 퇴근했다요~!"))
.addAction(android.R.drawable.ic_menu_directions, "앉음", makeSitDownAction())
.setOngoing(true) // 사용자가 알림을 스와이프로 지울 수 없게 만듦
.setProgress(max, progress, false)
.build())
}
fun makeSitDownAction(): PendingIntent {
val intent = Intent(this, ForeGroundService::class.java).apply {
action = ACTION_SIT_DOWN
}
return PendingIntent.getService(this, 1, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
}
fun makeSendMsgAction(code : Int, msg : String) : PendingIntent {
val actionIntent = Intent(this, ForeGroundService::class.java).apply {
action = ACTION_SENDMSG
@ -356,6 +405,66 @@ class ForeGroundService : Service() {
if (context == null) return
if (ActivityCompat.checkSelfPermission(context!!, Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) return
getPairedDevices()
if (action == BluetoothDevice.ACTION_ACL_CONNECTED) {
val device: BluetoothDevice? = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE)
// 연결된 기기가 오디오(이어폰, 스피커, 차량 블루투스)인지 확인
val majorClass = device?.bluetoothClass?.majorDeviceClass
if (majorClass == BluetoothClass.Device.Major.AUDIO_VIDEO) {
Blog.LOGE("Audio Bluetooth Connected: ${device?.name ?: "Unknown"}")
if ("BUMz pod".equals(device?.name)) {
launchAppleMusic(context)
}
}
}
}
}
private fun launchAppleMusic(context: Context) {
try {
val appleMusicPackage = "com.apple.android.music"
val launchIntent = context.packageManager.getLaunchIntentForPackage(appleMusicPackage)
if (launchIntent != null) {
// 서비스(백그라운드)에서 화면을 띄우려면 NEW_TASK 플래그가 필수입니다.
launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT)
context.startActivity(launchIntent)
Blog.LOGE("애플뮤직 자동 실행 완료")
CoroutineScope(Dispatchers.Main).launch {
delay(1500) // 1.5초 대기 (기기 속도에 따라 1000~2000 사이로 조절 가능)
val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
val eventTime = SystemClock.uptimeMillis()
// 재생 버튼 누름 (ACTION_DOWN)
val downEvent = KeyEvent(eventTime, eventTime, KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_PLAY, 0)
audioManager.dispatchMediaKeyEvent(downEvent)
// 재생 버튼 뗌 (ACTION_UP)
val upEvent = KeyEvent(eventTime, eventTime, KeyEvent.ACTION_UP, KeyEvent.KEYCODE_MEDIA_PLAY, 0)
audioManager.dispatchMediaKeyEvent(upEvent)
Blog.LOGE("애플뮤직 미디어 재생(Play) 명령 전송 완료 🎵")
delay(300) // 시스템이 재생 명령을 처리할 수 있도록 아주 잠깐 대기
// 💡 3. 홈 화면(런처)으로 즉시 강제 복귀
val homeIntent = Intent(Intent.ACTION_MAIN).apply {
addCategory(Intent.CATEGORY_HOME)
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
context.startActivity(homeIntent)
Blog.LOGE("런처(홈) 화면으로 복귀 완료")
}
} else {
Blog.LOGE("애플뮤직 앱이 기기에 설치되어 있지 않습니다.")
}
} catch (e: Exception) {
e.printStackTrace()
Blog.LOGE("애플뮤직 실행 중 오류 발생: ${e.message}")
}
}
}

View File

@ -4,14 +4,11 @@ import CustomVideoNodeRenderer
import android.annotation.SuppressLint
import android.app.Dialog
import android.content.Context
import android.content.DialogInterface
import android.content.Intent
import android.content.Intent.FLAG_ACTIVITY_CLEAR_TOP
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
import android.net.Uri
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.text.SpannableStringBuilder
import android.text.style.RelativeSizeSpan
import android.util.AttributeSet
@ -21,7 +18,6 @@ import android.view.KeyEvent
import android.view.MotionEvent
import android.view.PointerIcon
import android.view.View
import android.widget.CheckBox
import android.widget.EditText
import android.widget.LinearLayout
import android.widget.ProgressBar
@ -30,7 +26,6 @@ import android.widget.RadioGroup
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.core.content.ContentProviderCompat.requireContext
import androidx.core.net.toUri
import androidx.core.view.isVisible
import bums.lunatic.launcher.BookmarkUploader
@ -56,30 +51,28 @@ import com.yausername.youtubedl_android.YoutubeDLRequest
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONException
import org.json.JSONObject
import org.jsoup.Jsoup
import org.mozilla.gecko.util.ThreadUtils
import org.mozilla.geckoview.GeckoResult
import org.mozilla.geckoview.GeckoSession
import org.mozilla.geckoview.GeckoSession.CompositorScrollDelegate
import org.mozilla.geckoview.GeckoSessionSettings
import org.mozilla.geckoview.GeckoView
import org.mozilla.geckoview.MediaSession
import org.mozilla.geckoview.PanZoomController
import org.mozilla.geckoview.ScreenLength
import org.mozilla.geckoview.WebExtension
import org.mozilla.geckoview.WebExtension.MessageDelegate
import org.mozilla.geckoview.WebExtension.PortDelegate
import org.mozilla.geckoview.WebExtensionController.AddonManagerDelegate
import org.mozilla.geckoview.WebRequestError
import org.mozilla.geckoview.WebResponse
import java.io.File
import java.io.FileOutputStream
import java.io.InputStream
import java.text.SimpleDateFormat
import java.util.Date
import kotlin.jvm.java
// BWebview와 GeckoWeb을 통합한 클래스
open class GeckoWeb @JvmOverloads constructor(
@ -91,35 +84,29 @@ open class GeckoWeb @JvmOverloads constructor(
// 1. 세션 상태를 저장할 SharedPreferences 키
private val PREF_SESSION_STATE = "gecko_session_state"
// 2. 현재 세션 상태 저장 메서드
var sessionTag: String = "default" // 프레그먼트에서 설정할 고유 키
fun saveCurrentSessionState() {
lastSessionState?.let { state ->
// SessionState.toString()은 내부적으로 JSON 문자열을 반환합니다.
val stateJson = state.toString()
// 💡 키 이름에 sessionTag를 포함시켜 분리 저장
context.getSharedPreferences("GeckoPrefs", Context.MODE_PRIVATE)
.edit()
.putString("gecko_session_state", stateJson)
.putString("gecko_session_state_$sessionTag", stateJson)
.apply()
Log.d("GeckoWeb", "Session State Saved: $stateJson")
Log.d("GeckoWeb", "Saved State for $sessionTag")
}
}
// // 3. 저장된 세션 상태 복구 메서드
fun restoreSessionState(session: GeckoSession) {
fun restoreSessionState() {
// 💡 저장할 때와 동일한 태그로 불러오기
val stateJson = context.getSharedPreferences("GeckoPrefs", Context.MODE_PRIVATE)
.getString("gecko_session_state", null)
.getString("gecko_session_state_$sessionTag", null)
if (!stateJson.isNullOrEmpty()) {
// 문자열에서 SessionState 객체 생성
val state = GeckoSession.SessionState.fromString(stateJson)
Blog.LOGE("Restored state >>> ${state}")
if (state != null) {
session.restoreState(state)
Log.d("GeckoWeb", "Session State Restored")
session?.restoreState(state)
}
}
}
@ -339,6 +326,7 @@ open class GeckoWeb @JvmOverloads constructor(
R.id.reload -> view.setOnClickListener { session.reload() }
}
}
// scrollState = 0
}
override fun onCanGoBack(session: GeckoSession, canGoBack: Boolean) {
@ -433,7 +421,7 @@ open class GeckoWeb @JvmOverloads constructor(
}
buildWeb()
}
var lastScrollY = 0.0f
private fun buildWeb() {
getRuntime()?.let { runtime ->
val sessionSettings = GeckoSessionSettings.Builder()
@ -446,7 +434,7 @@ open class GeckoWeb @JvmOverloads constructor(
.build()
val session = GeckoSession(sessionSettings)
restoreSessionState(session)
session.open(runtime)
this.setSession(session)
@ -457,6 +445,15 @@ open class GeckoWeb @JvmOverloads constructor(
session.mediaDelegate = mediaDelegate
session.promptDelegate = promptDelegate
session.mediaSessionDelegate = mediaSessionDelegate
session.compositorScrollDelegate = object : CompositorScrollDelegate {
override fun onScrollChanged(
session: GeckoSession,
update: GeckoSession.ScrollPositionUpdate
) {
super.onScrollChanged(session, update)
lastScrollY = update.scrollY
}
}
runtime.settings.loginAutofillEnabled = true
runtime.webExtensionController.setAddonManagerDelegate(addonManagerDelegate)
@ -476,8 +473,38 @@ open class GeckoWeb @JvmOverloads constructor(
}
// --- 4. Helper Methods ---
fun checkScrollEndUsingMatrix(): Boolean {
val matrix = android.graphics.Matrix()
// 현재 페이지 좌표 -> 화면 좌표 변환 행렬 가져오기
session?.getPageToScreenMatrix(matrix)
// 웹페이지의 콘텐츠 높이를 짐작하기 위한 로직
// (GeckoView에서 scrollHeight를 직접 가져오기 어려우므로
// CompositorScrollDelegate에서 업데이트된 정보를 활용하거나
// 아래와 같이 Matrix의 역산 값을 활용합니다.)
val values = FloatArray(9)
matrix.getValues(values)
// values[Matrix.MTRANS_Y]는 현재 페이지의 Top이 화면상 어디에 있는지(y축 오프셋)를 나타냅니다.
// values[Matrix.MSCALE_Y]는 현재 줌 배율입니다.
val currentTopInScreen = values[android.graphics.Matrix.MTRANS_Y]
val currentZoom = values[android.graphics.Matrix.MSCALE_Y]
// 💡 논리:
// 손가락 스크롤이나 볼륨키 스크롤 모두 이 Matrix 값을 실시간으로 변화시킵니다.
// 특정 시점에 '스크롤 다운'을 명령했는데 이 Matrix의 변환 값(MTRANS_Y)이
// 더 이상 변하지 않는다면 그것이 물리적인 끝입니다.
return false // 실제 구현 시에는 이전 MTRANS_Y 값과 비교 로직 추가
}
private fun handlePortMessage(msg: PortMessage) {
when (msg.type) {
"SCROLL_STATE" -> {
Blog.LOGE("${msg.type} : ${msg.value}")
scrollState = msg.value?.toInt() ?: 0
}
"allImagesFound" -> lastedUrl?.let { startBookmarkSaveProcessForMultipleImages(it, msg.urls) }
"SINGLE_IMAGE_DATA" -> if (msg.imgSrc != null && msg.base64Data != null) {
Base64ImageCache.put(msg.imgSrc!!, msg.base64Data!!)
@ -517,7 +544,7 @@ open class GeckoWeb @JvmOverloads constructor(
}
return super.dispatchKeyEvent(ev)
}
var scrollState = 0
// Message Sending Helpers
fun sendScrollDown(isUp: Boolean) = sendJsonMsg("scrollDown", "isUpDown" to if(isUp) 1 else -1)
fun sendSearch(keyword: String) = sendJsonMsg("search", "keyword" to keyword)
@ -529,6 +556,29 @@ open class GeckoWeb @JvmOverloads constructor(
params.forEach { json.put(it.first, it.second) }
mPort?.postMessage(json)
}
var HH = 0.3
fun pageDown() {
val session = this.session ?: return
val pzc = session.panZoomController
// 💡 현재 눈에 보이는 GeckoView의 높이를 가져옵니다.
val viewHeight = this.height.toDouble().times(HH)
// 세로 방향(y)으로 뷰 높이만큼 아래로 스크롤 (픽셀 단위)
// 세 번째 인자는 애니메이션 여부입니다.
pzc.scrollBy(ScreenLength.fromPixels(0.0), ScreenLength.fromPixels(viewHeight),
PanZoomController.SCROLL_BEHAVIOR_SMOOTH)
}
fun pageUp() {
val session = this.session ?: return
val pzc = session.panZoomController
val viewHeight = this.height.toDouble().times(HH)
// 위로 스크롤
pzc.scrollBy(ScreenLength.fromPixels(0.0), ScreenLength.fromPixels(-viewHeight),
PanZoomController.SCROLL_BEHAVIOR_SMOOTH)
}
// File/Download Helpers
private fun downloadFile(url: String) {
@ -631,4 +681,15 @@ open class GeckoWeb @JvmOverloads constructor(
}
private fun View.post(action: () -> Unit) = this.post(Runnable(action))
fun onPause() {
saveCurrentSessionState()
session?.stop()
session?.setActive(false)
Blog.LOGE("called onstop $lastedUrl")
}
fun onResume() {
restoreSessionState()
session?.setActive(true)
}
}

View File

@ -31,6 +31,7 @@ import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import bums.lunatic.launcher.LunaticLauncher
import bums.lunatic.launcher.R
import bums.lunatic.launcher.common.CommonActivity
@ -61,6 +62,10 @@ import org.mozilla.geckoview.GeckoRuntimeSettings
import org.mozilla.geckoview.GeckoRuntimeSettings.ALLOW_ALL
import java.io.File
interface KeyEventHandler {
// 이벤트를 소비(consume)했으면 true, 아니면 false 반환
fun onKeyEvent(event: KeyEvent): Boolean
}
open class NeoRssActivity : CommonActivity() {
@ -95,6 +100,10 @@ open class NeoRssActivity : CommonActivity() {
}
}
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
setIntent(intent)
}
@SuppressLint("MissingSuperCall")
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
@ -154,33 +163,11 @@ open class NeoRssActivity : CommonActivity() {
return true
}
else {
val currentFragment = supportFragmentManager.findFragmentById(R.id.fragment_container)
Blog.LOGE("currentFragment >>> ${currentFragment} ${currentFragment is TokiFragment && currentFragment.isbooktoki()}")
if (currentFragment is TokiFragment && currentFragment.isbooktoki()) {
if(MotionEvent.ACTION_UP.equals(ev?.action ?: MotionEvent.ACTION_CANCEL) == true) {
return when (ev.keyCode) {
KeyEvent.KEYCODE_VOLUME_DOWN -> {
if(currentFragment is TokiFragment){ currentFragment.actionNextEvent() }
true
}
KeyEvent.KEYCODE_VOLUME_UP -> {
if(currentFragment is TokiFragment){ currentFragment.actionPrevEvent() }
true
}
else -> false
}
} else {
return when (ev.keyCode) {
KeyEvent.KEYCODE_VOLUME_DOWN -> {
true
}
KeyEvent.KEYCODE_VOLUME_UP -> {
true
}
else -> false
}
val currentFragment = fragmentMap.values.find { it.isAdded && (!it.isHidden || it.isVisible) }
if (currentFragment is KeyEventHandler) {
if (currentFragment.onKeyEvent(ev)) {
return true
}
}
else { return super.dispatchKeyEvent(ev) }
@ -373,7 +360,7 @@ open class NeoRssActivity : CommonActivity() {
startActivity(shareIntent)
}
}
showContents(R.id.feeds)
showContents(R.id.btn_info)
}
override fun onPause() {
@ -390,78 +377,64 @@ open class NeoRssActivity : CommonActivity() {
private var lastTouchY = 0f
private val fragmentMap = mutableMapOf<Int, Fragment>()
fun showContents(id : Int) {
binding.fragmentLayer.visibility = View.VISIBLE
binding.fragmentContainer.visibility = View.VISIBLE
binding.controllPanel.visibility = View.VISIBLE
binding.floatingActionMenu.visibility = View.VISIBLE
when(id) {
R.id.feeds -> {
supportFragmentManager.beginTransaction()
.replace(R.id.fragment_container, RssHome())
.commit()
val transaction = supportFragmentManager.beginTransaction()
fragmentMap.values.forEach { fragment ->
if (fragment.isAdded) {
transaction.hide(fragment)
}
}
// 2. 요청된 ID에 해당하는 프래그먼트가 이미 생성되었는지 확인
var targetFragment = fragmentMap[id]
if (targetFragment == null) {
targetFragment = supportFragmentManager.findFragmentByTag("TAG_$id")
// 시스템이 복구해 놓은 게 있다면, 맵에 다시 등록(싱크 맞추기)
if (targetFragment != null) {
fragmentMap[id] = targetFragment
}
R.id.books ->{
supportFragmentManager.beginTransaction()
.replace(R.id.fragment_container, TokiFragment.newInstanceNovels())
.commit()
}
R.id.webtoons ->{
supportFragmentManager.beginTransaction()
.replace(R.id.fragment_container, TokiFragment.newInstanceWebtoons())
.commit()
}
R.id.comics ->{
supportFragmentManager.beginTransaction()
.replace(R.id.fragment_container, TokiFragment.newInstanceComics())
.commit()
}
R.id.youtube ->{
supportFragmentManager.beginTransaction()
.replace(R.id.fragment_container, TokiFragment.newInstanceYouTube())
.commit()
}
R.id.perplexity ->{
supportFragmentManager.beginTransaction()
.replace(R.id.fragment_container, TokiFragment.newInstancePerplexity())
.commit()
}
R.id.zzalbang ->{
supportFragmentManager.beginTransaction()
.replace(R.id.fragment_container, BookmarkPagerFragment())
.commit()
}
R.id.btn_x ->{
supportFragmentManager.beginTransaction()
.replace(R.id.fragment_container, TokiFragment.newInstanceX())
.commit()
}
R.id.btn_torrent ->{
supportFragmentManager.beginTransaction()
.replace(R.id.fragment_container, TorrentListFragment())
.commit()
}
R.id.close ->{
supportFragmentManager.findFragmentById(R.id.fragment_container)?.let {
supportFragmentManager.beginTransaction()
.remove(it)
.commit()
binding.fragmentLayer.visibility = View.GONE
binding.fragmentContainer.visibility = View.GONE
binding.controllPanel.visibility = View.GONE
binding.floatingActionMenu.visibility = View.GONE
}
// sRuntime?.shutdown()
// sRuntime = null
if (targetFragment == null) {
// 처음 호출되는 메뉴라면 인스턴스 생성 및 추가
targetFragment = when(id) {
R.id.feeds -> RssHome()
R.id.books -> TokiFragment.newInstanceNovels()
R.id.webtoons -> TokiFragment.newInstanceWebtoons()
R.id.comics -> TokiFragment.newInstanceComics()
R.id.youtube -> TokiFragment.newInstanceYouTube()
R.id.perplexity -> TokiFragment.newInstancePerplexity()
R.id.zzalbang -> BookmarkPagerFragment()
R.id.btn_x -> TokiFragment.newInstanceX()
R.id.btn_i -> TokiFragment.newInstanceI()
R.id.btn_torrent -> TorrentListFragment()
R.id.btn_info -> SystemStatusFragment()
R.id.close -> {
finish()
return
}
else -> {}
else -> null
}
targetFragment?.let {
fragmentMap[id] = it
transaction.add(R.id.fragment_container, it, "TAG_$id")
}
} else {
// 이미 생성된 프래그먼트가 있다면 다시 보여줌
transaction.show(targetFragment)
}
transaction.commit()
binding.floatingActionMenu.close(false)
}
@ -524,14 +497,14 @@ open class NeoRssActivity : CommonActivity() {
currentFragment.doNextPage()
}
}
// is YouTube -> {
// currentFragment.back()
// }
is TokiFragment -> {
currentFragment.back()
}
// is Novels -> {
// currentFragment.actionNextEvent(false)
// }
else -> {
showContents(R.id.close)
// showContents(R.id.close)
}
}
}

View File

@ -27,6 +27,7 @@ import android.net.Uri
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.KeyEvent
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.PointerIcon
@ -82,7 +83,7 @@ import java.text.SimpleDateFormat
import java.util.Date
internal class RssHome : Fragment() {
internal class RssHome : Fragment() , KeyEventHandler {
lateinit var binding: LauncherHomeBinding
private lateinit var fragManager: FragmentManager
@ -213,6 +214,36 @@ internal class RssHome : Fragment() {
home = this
}
override fun onKeyEvent(event: KeyEvent): Boolean {
val isUp = event.action == MotionEvent.ACTION_UP
return when (event.keyCode) {
KeyEvent.KEYCODE_VOLUME_DOWN -> {
if (isUp) {
if (binding.geckoWeb.scrollState > 0) {
// 사용자가 손으로 끝까지 내렸든, 볼륨키로 내렸든 상관없이 이곳에 도달함
doNextPage()
} else {
binding.geckoWeb.pageDown()
}
}
true
}
KeyEvent.KEYCODE_VOLUME_UP -> {
if (isUp) {
if (binding.geckoWeb.scrollState < 0) {
binding.geckoWeb.session?.goBack()
} else {
binding.geckoWeb.pageUp()
}
}
true
}
else -> false
}
}
var targetList = arrayListOf<String>()
val dateViewClick = View.OnClickListener { v ->
Blog.LOGE("click view >> ${v}")
@ -576,7 +607,7 @@ internal class RssHome : Fragment() {
binding.geckoWeb.decoViews.add(activity.findViewById<ImageButton>(R.id.reload))
binding.geckoWeb.decoViews.add(activity.findViewById<ImageButton>(R.id.dl_video))
}
binding.geckoWeb.restoreSessionState()
return binding.root
}
@ -584,6 +615,20 @@ internal class RssHome : Fragment() {
binding.geckoWeb?.saveMd(true)
}
// TokiFragment 또는 GeckoView를 사용하는 프래그먼트 내부
override fun onHiddenChanged(hidden: Boolean) {
super.onHiddenChanged(hidden)
if (hidden) {
// 💡 화면에서 사라질 때: 타이머 중지 및 애니메이션 중지
binding.geckoWeb?.onPause()
// 일반 WebView라면: webView.onPause() 및 webView.pauseTimers()
} else {
// 💡 다시 나타날 때: 다시 시작
// binding.geckoWeb?.onResume()
// 일반 WebView라면: webView.onResume() 및 webView.resumeTimers()
}
}
@SuppressLint("NewApi")
fun doNextPage() {

View File

@ -0,0 +1,582 @@
package bums.lunatic.launcher.home
import android.app.ActivityManager
import android.app.AppOpsManager
import android.app.NotificationManager
import android.app.usage.NetworkStatsManager
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothProfile
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.graphics.Color
import android.media.AudioDeviceInfo
import android.media.AudioManager
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.net.TrafficStats
import android.net.Uri
import android.net.wifi.WifiManager
import android.os.BatteryManager
import android.os.Build
import android.os.Bundle
import android.os.Environment
import android.os.StatFs
import android.os.SystemClock
import android.provider.Settings
import android.util.TypedValue
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.SeekBar
import android.widget.TextView
import android.widget.Toast
import androidx.fragment.app.Fragment
import bums.lunatic.launcher.databinding.FragmentSystemStatusBinding
import bums.lunatic.launcher.utils.Blog
import kotlinx.coroutines.*
import java.util.Calendar
class SystemStatusFragment : Fragment() {
private var _binding: FragmentSystemStatusBinding? = null
private val binding get() = _binding!!
private var monitorJob: Job? = null
private var lastRxBytes: Long = 0
private var lastTxBytes: Long = 0
private var lastUpdateTime: Long = 0
private lateinit var audioManager: AudioManager
// 앱 데이터 사용량 계산용 데이터 클래스
data class AppUsage(val uid: Int, var name: String = "", var wifiBytes: Long = 0L, var mobileBytes: Long = 0L) {
val totalBytes get() = wifiBytes + mobileBytes
}
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = FragmentSystemStatusBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
audioManager = requireContext().getSystemService(Context.AUDIO_SERVICE) as AudioManager
setupClickListeners()
setupQuickControls()
resetNetworkCounters()
startMonitoring()
updateMonthlyDataUsage()
}
private fun setupClickListeners() {
binding.textMonthlyUsage.setOnClickListener {
if (!hasUsageStatsPermission()) {
startActivity(Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS))
} else {
updateMonthlyDataUsage()
}
}
binding.textStorage.setOnClickListener {
try {
startActivity(Intent(Settings.ACTION_INTERNAL_STORAGE_SETTINGS))
} catch (e: Exception) {
startActivity(Intent(Settings.ACTION_SETTINGS))
}
}
binding.progressStorage.setOnClickListener { binding.textStorage.performClick() }
binding.textRam.setOnClickListener {
try {
val samsungIntent = Intent().apply {
setClassName("com.samsung.android.lool", "com.samsung.android.sm.ui.ram.RamActivity")
}
startActivity(samsungIntent)
} catch (e: ActivityNotFoundException) {
try {
startActivity(Intent(Settings.ACTION_APPLICATION_SETTINGS))
Toast.makeText(requireContext(), "앱 설정 화면으로 이동합니다.", Toast.LENGTH_SHORT).show()
} catch (ex: Exception) {
startActivity(Intent(Settings.ACTION_SETTINGS))
}
}
}
binding.progressRam.setOnClickListener { binding.textRam.performClick() }
binding.textBattery.setOnClickListener {
try {
startActivity(Intent(Intent.ACTION_POWER_USAGE_SUMMARY))
} catch (e: Exception) {
startActivity(Intent(Settings.ACTION_SETTINGS))
}
}
binding.progressBattery.setOnClickListener { binding.textBattery.performClick() }
}
private fun setupQuickControls() {
binding.btnQuickWifi.setOnClickListener {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startActivity(Intent(Settings.Panel.ACTION_WIFI))
} else {
startActivity(Intent(Settings.ACTION_WIFI_SETTINGS))
}
}
binding.btnQuickBt.setOnClickListener {
startActivity(Intent(Settings.ACTION_BLUETOOTH_SETTINGS))
}
binding.btnQuickRinger.setOnClickListener {
val nm = requireContext().getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && !nm.isNotificationPolicyAccessGranted) {
Toast.makeText(requireContext(), "무음 모드 제어를 위해 권한을 허용해주세요.", Toast.LENGTH_LONG).show()
startActivity(Intent(Settings.ACTION_NOTIFICATION_POLICY_ACCESS_SETTINGS))
return@setOnClickListener
}
when (audioManager.ringerMode) {
AudioManager.RINGER_MODE_NORMAL -> audioManager.ringerMode = AudioManager.RINGER_MODE_VIBRATE
AudioManager.RINGER_MODE_VIBRATE -> audioManager.ringerMode = AudioManager.RINGER_MODE_SILENT
AudioManager.RINGER_MODE_SILENT -> audioManager.ringerMode = AudioManager.RINGER_MODE_NORMAL
}
updateQuickControlUI()
}
binding.seekVolume.max = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
binding.seekVolume.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
if (fromUser) audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, progress, 0)
}
override fun onStartTrackingTouch(seekBar: SeekBar?) {}
override fun onStopTrackingTouch(seekBar: SeekBar?) {}
})
binding.seekBrightness.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
if (!fromUser) return
if (Settings.System.canWrite(requireContext())) {
val cr = requireContext().contentResolver
Settings.System.putInt(cr, Settings.System.SCREEN_BRIGHTNESS_MODE, Settings.System.SCREEN_BRIGHTNESS_MODE_MANUAL)
Settings.System.putInt(cr, Settings.System.SCREEN_BRIGHTNESS, progress)
} else {
Toast.makeText(requireContext(), "밝기 조절을 위해 '시스템 설정 수정' 권한이 필요합니다.", Toast.LENGTH_LONG).show()
val intent = Intent(Settings.ACTION_MANAGE_WRITE_SETTINGS).apply {
data = Uri.parse("package:${requireContext().packageName}")
}
startActivity(intent)
}
}
override fun onStartTrackingTouch(seekBar: SeekBar?) {}
override fun onStopTrackingTouch(seekBar: SeekBar?) {}
})
updateQuickControlUI()
}
private fun updateQuickControlUI() {
val ringerText = when (audioManager.ringerMode) {
AudioManager.RINGER_MODE_NORMAL -> "🔔 소리"
AudioManager.RINGER_MODE_VIBRATE -> "📳 진동"
AudioManager.RINGER_MODE_SILENT -> "🔕 무음"
else -> "소리"
}
binding.btnQuickRinger.text = ringerText
binding.seekVolume.progress = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
try {
val brightness = Settings.System.getInt(requireContext().contentResolver, Settings.System.SCREEN_BRIGHTNESS)
binding.seekBrightness.progress = brightness
} catch (e: Settings.SettingNotFoundException) { }
}
override fun onHiddenChanged(hidden: Boolean) {
super.onHiddenChanged(hidden)
if (hidden) {
stopMonitoring()
} else {
resetNetworkCounters()
startMonitoring()
updateMonthlyDataUsage()
updateQuickControlUI()
}
}
override fun onPause() {
super.onPause()
stopMonitoring()
}
override fun onResume() {
super.onResume()
if (!isHidden) {
resetNetworkCounters()
startMonitoring()
updateMonthlyDataUsage()
updateQuickControlUI()
}
}
private fun resetNetworkCounters() {
lastRxBytes = TrafficStats.getTotalRxBytes()
lastTxBytes = TrafficStats.getTotalTxBytes()
lastUpdateTime = System.currentTimeMillis()
}
private fun startMonitoring() {
if (monitorJob?.isActive == true) return
monitorJob = CoroutineScope(Dispatchers.Main).launch {
while (isActive) {
updateSystemInfo()
updateNetworkSpeed()
if (!binding.seekVolume.isPressed && !binding.seekBrightness.isPressed) {
updateQuickControlUI()
}
delay(2000)
}
}
}
private fun stopMonitoring() {
monitorJob?.cancel()
monitorJob = null
}
private fun updateSystemInfo() {
val context = requireContext()
// 1. RAM
val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
val memoryInfo = ActivityManager.MemoryInfo()
activityManager.getMemoryInfo(memoryInfo)
val totalRam = memoryInfo.totalMem / (1024 * 1024)
val availRam = memoryInfo.availMem / (1024 * 1024)
val usedRam = totalRam - availRam
val ramPercent = if (totalRam > 0) (usedRam.toFloat() / totalRam.toFloat() * 100).toInt() else 0
binding.textRam.text = "RAM: ${usedRam}MB / ${totalRam}MB ($ramPercent%)"
binding.progressRam.progress = ramPercent
// 2. Storage
val statFs = StatFs(Environment.getDataDirectory().path)
val totalStorage = statFs.totalBytes / (1024 * 1024 * 1024)
val availStorage = statFs.availableBytes / (1024 * 1024 * 1024)
val usedStorage = totalStorage - availStorage
val storagePercent = if (totalStorage > 0) (usedStorage.toFloat() / totalStorage.toFloat() * 100).toInt() else 0
binding.textStorage.text = "Storage: ${usedStorage}GB / ${totalStorage}GB ($storagePercent%)"
binding.progressStorage.progress = storagePercent
// 3. Battery
val batteryStatus: Intent? = IntentFilter(Intent.ACTION_BATTERY_CHANGED).let { ifilter ->
context.registerReceiver(null, ifilter)
}
batteryStatus?.let { intent ->
val level: Int = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1)
val scale: Int = intent.getIntExtra(BatteryManager.EXTRA_SCALE, -1)
val batteryPct = if (scale > 0) level * 100 / scale.toFloat() else 0f
val tempInt = intent.getIntExtra(BatteryManager.EXTRA_TEMPERATURE, 0)
val temperature = tempInt / 10f
val status = intent.getIntExtra(BatteryManager.EXTRA_STATUS, -1)
val isCharging = status == BatteryManager.BATTERY_STATUS_CHARGING || status == BatteryManager.BATTERY_STATUS_FULL
val chargePlug = intent.getIntExtra(BatteryManager.EXTRA_PLUGGED, -1)
val chargingText = when {
status == BatteryManager.BATTERY_STATUS_FULL -> "충전 완료 (100%)"
isCharging && chargePlug == BatteryManager.BATTERY_PLUGGED_AC -> "충전 중 (AC 어댑터)"
isCharging && chargePlug == BatteryManager.BATTERY_PLUGGED_USB -> "충전 중 (USB 연결)"
isCharging && chargePlug == BatteryManager.BATTERY_PLUGGED_WIRELESS -> "충전 중 (무선 충전)"
isCharging -> "충전 중"
else -> "방전 중"
}
binding.textChargingStatus.text = "Status: $chargingText"
binding.textBattery.text = "Battery: ${batteryPct.toInt()}%"
binding.progressBattery.progress = batteryPct.toInt()
if (temperature >= 40.0f) {
binding.textTemperature.setTextColor(Color.parseColor("#F44336"))
} else {
binding.textTemperature.setTextColor(Color.parseColor("#FF9800"))
}
binding.textTemperature.text = "Temp: ${temperature}°C"
}
// 4. Connectivity
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val activeNetwork = cm.activeNetwork
val caps = cm.getNetworkCapabilities(activeNetwork)
var networkName = "오프라인"
if (caps != null) {
when {
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> {
networkName = "Wi-Fi"
try {
val wifiManager = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
var ssid = wifiManager.connectionInfo.ssid
if (ssid.startsWith("\"") && ssid.endsWith("\"")) {
ssid = ssid.substring(1, ssid.length - 1)
}
if (ssid != "<unknown ssid>" && ssid.isNotBlank()) {
networkName = "Wi-Fi ($ssid)"
}
} catch (e: SecurityException) { }
}
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> networkName = "Mobile Data"
caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> networkName = "Ethernet"
caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN) -> networkName = "VPN"
}
}
binding.textNetworkState.text = "Net: $networkName"
// 5. Bluetooth
var btState = "OFF"
var connectedDevicesStr = ""
try {
val btManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
val btAdapter = btManager.adapter
if (btAdapter?.isEnabled == true) {
btState = "ON"
val connectedNames = mutableSetOf<String>()
val audioDevices = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
audioDevices.forEach { device ->
if (device.type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP ||
device.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO ||
device.type == AudioDeviceInfo.TYPE_BLE_HEADSET ||
device.type == AudioDeviceInfo.TYPE_BLE_SPEAKER) {
if (!device.productName.isNullOrBlank()) {
connectedNames.add(device.productName.toString())
}
}
}
try {
btManager.getConnectedDevices(BluetoothProfile.GATT).forEach { device ->
if (!device.name.isNullOrBlank()) {
connectedNames.add(device.name)
}
}
} catch (e: SecurityException) { }
if (connectedNames.isNotEmpty()) {
connectedDevicesStr = " (${connectedNames.joinToString(", ")})"
}
}
} catch (e: SecurityException) {
btState = "권한 필요"
}
binding.textBluetoothState.text = "BT: $btState$connectedDevicesStr"
// 6. Uptime
val uptimeMillis = SystemClock.elapsedRealtime()
val hours = (uptimeMillis / (1000 * 60 * 60)).toInt()
val minutes = ((uptimeMillis / (1000 * 60)) % 60).toInt()
val seconds = ((uptimeMillis / 1000) % 60).toInt()
binding.textUptime.text = String.format("System Uptime: %02d:%02d:%02d", hours, minutes, seconds)
}
private fun updateNetworkSpeed() {
val currentRxBytes = TrafficStats.getTotalRxBytes()
val currentTxBytes = TrafficStats.getTotalTxBytes()
val currentTime = System.currentTimeMillis()
if (currentRxBytes == TrafficStats.UNSUPPORTED.toLong() || lastUpdateTime == 0L) return
val timeDiff = (currentTime - lastUpdateTime) / 1000f
if (timeDiff > 0) {
val rxSpeed = (currentRxBytes - lastRxBytes) / timeDiff
val txSpeed = (currentTxBytes - lastTxBytes) / timeDiff
binding.textDownload.text = "↓ DL: ${formatSpeed(rxSpeed)}"
binding.textUpload.text = "↑ UL: ${formatSpeed(txSpeed)}"
}
lastRxBytes = currentRxBytes
lastTxBytes = currentTxBytes
lastUpdateTime = currentTime
}
// 💡 앱별 다중 색상 데이터 사용량 뷰 생성 로직
private fun updateMonthlyDataUsage() {
if (!hasUsageStatsPermission()) {
binding.textMonthlyUsage.text = "Monthly Usage: 권한 필요 (여기를 클릭하여 허용)"
binding.layoutTopApps.visibility = View.GONE
return
}
CoroutineScope(Dispatchers.IO).launch {
try {
val networkStatsManager = requireContext().getSystemService(Context.NETWORK_STATS_SERVICE) as NetworkStatsManager
val calendar = Calendar.getInstance()
calendar.set(Calendar.DAY_OF_MONTH, 1)
calendar.set(Calendar.HOUR_OF_DAY, 0)
val startTime = calendar.timeInMillis
val endTime = System.currentTimeMillis()
val wifiBucket = networkStatsManager.querySummaryForDevice(ConnectivityManager.TYPE_WIFI, null, startTime, endTime)
val mobileBucket = networkStatsManager.querySummaryForDevice(ConnectivityManager.TYPE_MOBILE, null, startTime, endTime)
val wifiTotal = wifiBucket.rxBytes + wifiBucket.txBytes
val mobileTotal = mobileBucket.rxBytes + mobileBucket.txBytes
val appUsageMap = mutableMapOf<Int, AppUsage>()
fun aggregateStats(networkType: Int, isWifi: Boolean) {
try {
val networkStats = networkStatsManager.querySummary(networkType, null, startTime, endTime)
val bucket = android.app.usage.NetworkStats.Bucket()
while (networkStats.hasNextBucket()) {
networkStats.getNextBucket(bucket)
val uid = bucket.uid
val totalBytes = bucket.rxBytes + bucket.txBytes
val appUsage = appUsageMap.getOrPut(uid) { AppUsage(uid) }
if (isWifi) appUsage.wifiBytes += totalBytes
else appUsage.mobileBytes += totalBytes
}
networkStats.close()
} catch (e: Exception) { e.printStackTrace() }
}
aggregateStats(ConnectivityManager.TYPE_WIFI, true)
aggregateStats(ConnectivityManager.TYPE_MOBILE, false)
val packageManager = requireContext().packageManager
val topApps = appUsageMap.values
.filter { it.totalBytes > 0 }
.sortedByDescending { it.totalBytes }
.take(10)
topApps.forEach { app ->
app.name = "Unknown (UID: ${app.uid})"
try {
val packages = packageManager.getPackagesForUid(app.uid)
if (!packages.isNullOrEmpty()) {
val appInfo = packageManager.getApplicationInfo(packages[0], 0)
app.name = packageManager.getApplicationLabel(appInfo).toString()
}
} catch (e: Exception) { }
}
val maxBytes = topApps.maxOfOrNull { it.totalBytes }?.toFloat() ?: 1f
withContext(Dispatchers.Main) {
// 전체 요약 (색상 아이콘 추가)
binding.textMonthlyUsage.text = "이번 달 누적 (Wi-Fi 🟩: ${formatSize(wifiTotal)} / 모바일 🟦: ${formatSize(mobileTotal)})"
val container = binding.layoutTopApps
container.removeAllViews()
container.visibility = View.VISIBLE
if (topApps.isEmpty()) {
val emptyText = TextView(context).apply {
text = "앱별 데이터 없음"
setTextColor(Color.parseColor("#BBBBBB"))
}
container.addView(emptyText)
return@withContext
}
// 💡 동적 멀티 컬러 프로그레스바 그리기 (LinearLayout + layout_weight)
topApps.forEachIndexed { index, app ->
// 텍스트 라벨
val textView = TextView(context).apply {
text = "${index + 1}. ${app.name}\n(총: ${formatSize(app.totalBytes)} | Wi-Fi 🟩: ${formatSize(app.wifiBytes)} | 모바일 🟦: ${formatSize(app.mobileBytes)})"
setTextColor(Color.parseColor("#CCCCCC"))
setTextSize(TypedValue.COMPLEX_UNIT_SP, 12f)
setPadding(0, 16, 0, 4)
}
// 전체 게이지 바 컨테이너
val barContainer = LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
weightSum = maxBytes
val heightPx = (10 * resources.displayMetrics.density).toInt()
val marginPx = (8 * resources.displayMetrics.density).toInt()
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, heightPx).apply {
setMargins(0, 0, 0, marginPx)
}
setBackgroundColor(Color.parseColor("#333333")) // 빈 부분 어두운 배경
}
// 1. Wi-Fi 사용량 막대 (초록색)
if (app.wifiBytes > 0) {
val wifiView = View(context).apply {
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.MATCH_PARENT, app.wifiBytes.toFloat())
setBackgroundColor(Color.parseColor("#4CAF50"))
}
barContainer.addView(wifiView)
}
// 2. 모바일 사용량 막대 (파란색)
if (app.mobileBytes > 0) {
val mobileView = View(context).apply {
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.MATCH_PARENT, app.mobileBytes.toFloat())
setBackgroundColor(Color.parseColor("#2196F3"))
}
barContainer.addView(mobileView)
}
// 3. 남은 여백 (가장 많이 쓴 앱 기준 스케일 맞추기 위함)
val emptyWeight = maxBytes - app.totalBytes.toFloat()
if (emptyWeight > 0) {
val emptyView = View(context).apply {
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.MATCH_PARENT, emptyWeight)
}
barContainer.addView(emptyView)
}
container.addView(textView)
container.addView(barContainer)
}
}
} catch (e: Exception) {
e.printStackTrace()
}
}
}
private fun hasUsageStatsPermission(): Boolean {
val appOps = requireContext().getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager
val mode = appOps.checkOpNoThrow(AppOpsManager.OPSTR_GET_USAGE_STATS, android.os.Process.myUid(), requireContext().packageName)
return mode == AppOpsManager.MODE_ALLOWED
}
private fun formatSpeed(bytesPerSec: Float): String {
return when {
bytesPerSec >= 1024 * 1024 -> String.format("%.2f MB/s", bytesPerSec / (1024 * 1024))
bytesPerSec >= 1024 -> String.format("%.1f KB/s", bytesPerSec / 1024)
else -> "${bytesPerSec.toInt()} B/s"
}
}
private fun formatSize(bytes: Long): String {
return when {
bytes >= 1024 * 1024 * 1024 -> String.format("%.2f GB", bytes / (1024f * 1024f * 1024f))
bytes >= 1024 * 1024 -> String.format("%.1f MB", bytes / (1024f * 1024f))
else -> "${bytes / 1024} KB"
}
}
override fun onDestroyView() {
super.onDestroyView()
stopMonitoring()
_binding = null
}
}

View File

@ -39,7 +39,7 @@ class TorrentListAdapter(
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val task = tasks[position]
holder.tvName.text = task.name
holder.tvName.text = task.name.plus("(${task.stateText})")
holder.progressBar.progress = task.progress.toInt()
holder.tvProgress.text = String.format("%.1f%%", task.progress)

View File

@ -103,6 +103,7 @@ class PortMessage {
var urls : List<String> = emptyList()
var imgSrc: String? = null
var base64Data: String? = null
var value : String? = null
}
class BookContents {
var chapterTitle : String? = null

View File

@ -12,6 +12,7 @@ import android.text.InputType
import android.text.SpannableStringBuilder
import android.text.style.RelativeSizeSpan
import android.util.Log
import android.view.KeyEvent
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.PointerIcon
@ -34,6 +35,7 @@ import bums.lunatic.launcher.R
import bums.lunatic.launcher.databinding.BooktokiBinding
import bums.lunatic.launcher.home.GeckoWeb
import bums.lunatic.launcher.home.GeckoWeb.JxEvent
import bums.lunatic.launcher.home.KeyEventHandler
import bums.lunatic.launcher.home.NeoRssActivity
import bums.lunatic.launcher.home.toast
import bums.lunatic.launcher.home.tokiz.view.PagedTextLayout
@ -64,12 +66,12 @@ import java.util.Date
import kotlin.random.Random
// 기존 BaseToki 및 하위 클래스들을 통합한 단일 클래스
class TokiFragment : Fragment(), PagedTextViewInterface {
class TokiFragment : Fragment(), PagedTextViewInterface,KeyEventHandler {
// --- Configuration Properties (Arguments에서 로드) ---
private lateinit var contentsType: String
private var lastNumber: Int = 0
private lateinit var webcontentsName: String
lateinit var webcontentsName: String
private lateinit var afterDot: String
private var useNumberInUrl: Boolean = true
private var enableGestures: Boolean = true
@ -160,6 +162,17 @@ class TokiFragment : Fragment(), PagedTextViewInterface {
}
}
fun newInstanceI(): TokiFragment = TokiFragment().apply {
arguments = Bundle().apply {
putString(ARG_TYPE, "ijavtorrent")
putInt(ARG_LAST_NUM, 143)
putString(ARG_NAME, "ijavtorrent")
putString(ARG_DOT, "com")
putBoolean(ARG_USE_NUM_URL, false)
putBoolean(ARG_ENABLE_GESTURE, false)
}
}
fun newInstanceNovels(): TokiFragment = TokiFragment().apply {
arguments = Bundle().apply {
putString(ARG_TYPE, "book")
@ -236,6 +249,24 @@ class TokiFragment : Fragment(), PagedTextViewInterface {
fun isbooktoki() : Boolean {
return webcontentsName.contains("booktoki")
}
override fun onKeyEvent(event: KeyEvent): Boolean {
if (!isbooktoki()) return false // 조건부 처리
val isUp = event.action == MotionEvent.ACTION_UP
return when (event.keyCode) {
KeyEvent.KEYCODE_VOLUME_DOWN -> {
if (isUp) actionNextEvent()
true
}
KeyEvent.KEYCODE_VOLUME_UP -> {
if (isUp) actionPrevEvent()
true
}
else -> false
}
}
override fun onSwipeLeft(count: Int) {
if (!enableGestures) return
Blog.LOGD(log = "onSwipeLeft ${count}")
@ -283,8 +314,7 @@ class TokiFragment : Fragment(), PagedTextViewInterface {
if (contentsType == "youtube") {
binding.menuWeb.session?.goBack()
} else {
// binding.menuWeb.session?.goBack()
// 기존 BaseToki 주석 처리됨
actionNextEvent(false)
}
}
@ -395,6 +425,21 @@ class TokiFragment : Fragment(), PagedTextViewInterface {
return workingUrl
}
}
override fun onHiddenChanged(hidden: Boolean) {
super.onHiddenChanged(hidden)
if (hidden) {
// 💡 화면에서 사라질 때: 타이머 중지 및 애니메이션 중지
binding.menuWeb?.onPause()
// 일반 WebView라면: webView.onPause() 및 webView.pauseTimers()
} else {
// 💡 다시 나타날 때: 다시 시작
// binding.menuWeb?.onResume()
// 일반 WebView라면: webView.onResume() 및 webView.resumeTimers()
}
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
@ -403,6 +448,7 @@ class TokiFragment : Fragment(), PagedTextViewInterface {
Blog.LOGD(log = "onCreate ${this::class.java.name} >> savedInstanceState ${savedInstanceState}")
binding = BooktokiBinding.inflate(inflater)
binding.menuWeb.let {
it.sessionTag = webcontentsName
it.visibility = View.VISIBLE
it.lastDomain = getLastedDoamin()
it.setOnLongClickListener {
@ -416,27 +462,27 @@ class TokiFragment : Fragment(), PagedTextViewInterface {
if (lastedUrl?.contains("youtube.com") == true) {
} else {
binding.menuWeb.postDelayed({
Blog.LOGE("onPageStop $success from WebExtension ${mPort!!.name}")
val message: JSONObject = JSONObject()
try {
message.put("type", "getList")
message.put("event", "sadsadds")
// message.put("tab", session.settings.screenId)
} catch (ex: JSONException) {
throw RuntimeException(ex)
}
mPort!!.postMessage(message)
// 타입별 분기 처리 (기존 when 절 대체)
if (contentsType == "comics" || contentsType == "webtoon") {
lastInfo
}
binding.progress.visibility = GONE
}, 10L)
// binding.menuWeb.postDelayed({
//
// Blog.LOGE("onPageStop $success from WebExtension ${mPort!!.name}")
// val message: JSONObject = JSONObject()
// try {
// message.put("type", "getList")
// message.put("event", "sadsadds")
//// message.put("tab", session.settings.screenId)
// } catch (ex: JSONException) {
// throw RuntimeException(ex)
// }
//
// mPort!!.postMessage(message)
//
//
// // 타입별 분기 처리 (기존 when 절 대체)
// if (contentsType == "comics" || contentsType == "webtoon") {
// lastInfo
// }
// binding.progress.visibility = GONE
// }, 10L)
}
}
}
@ -531,6 +577,7 @@ class TokiFragment : Fragment(), PagedTextViewInterface {
it.decoViews.add(activity.findViewById<ImageButton>(R.id.reload))
it.decoViews.add(activity.findViewById<ImageButton>(R.id.dl_video))
}
it.restoreSessionState()
}
binding.btnList.setOnClickListener { v ->

View File

@ -106,6 +106,14 @@ class FloatingActionMenu @JvmOverloads constructor(
private var isDragMenuDisabled = false
// 💡 라벨 위치 동적 변경을 위한 프로퍼티
var labelsPosition: Int
get() = mLabelsPosition
set(position) {
mLabelsPosition = position
requestLayout()
}
interface OnMenuToggleListener {
fun onMenuToggle(opened: Boolean)
}
@ -388,8 +396,14 @@ class FloatingActionMenu @JvmOverloads constructor(
if (label != null) {
val labelOffset =
(mMaxButtonWidth - child.getMeasuredWidth()) / (if (mUsingMenuLabel) 1 else 2)
val labelUsedWidth: Int =
// 💡 [수정] Center 옵션일 경우 라벨을 버튼 위로 겹쳐 그리므로 공간을 추가하지 않음
val labelUsedWidth: Int = if (mLabelsPosition == LABELS_POSITION_CENTER) {
child.getMeasuredWidth()
} else {
child.getMeasuredWidth() + label.calculateShadowWidth() + mLabelsMargin + labelOffset
}
measureChildWithMargins(
label,
widthMeasureSpec,
@ -397,9 +411,11 @@ class FloatingActionMenu @JvmOverloads constructor(
heightMeasureSpec,
0
)
if (mLabelsPosition != LABELS_POSITION_CENTER) {
usedWidth += label.getMeasuredWidth()
maxLabelWidth =
max(maxLabelWidth.toDouble(), (usedWidth + labelOffset).toDouble()).toInt()
maxLabelWidth = max(maxLabelWidth.toDouble(), (usedWidth + labelOffset).toDouble()).toInt()
}
}
}
@ -483,6 +499,16 @@ class FloatingActionMenu @JvmOverloads constructor(
val label = fab.getTag(R.id.fab_label) as View?
if (label != null) {
val labelLeft: Int
val labelRight: Int
val labelTop: Int
// 💡 [수정] Center 옵션일 경우 X,Y축 모두 버튼의 중앙으로 계산
if (mLabelsPosition == LABELS_POSITION_CENTER) {
labelLeft = childX + (fab.getMeasuredWidth() - label.getMeasuredWidth()) / 2
labelRight = labelLeft + label.getMeasuredWidth()
labelTop = childY + (fab.getMeasuredHeight() - label.getMeasuredHeight()) / 2
} else {
val labelsOffset =
(if (mUsingMenuLabel) mMaxButtonWidth / 2 else fab.getMeasuredWidth() / 2) + mLabelsMargin
val labelXNearButton = if (mLabelsPosition == LABELS_POSITION_LEFT)
@ -495,18 +521,18 @@ class FloatingActionMenu @JvmOverloads constructor(
else
labelXNearButton + label.getMeasuredWidth()
val labelLeft = if (mLabelsPosition == LABELS_POSITION_LEFT)
labelLeft = if (mLabelsPosition == LABELS_POSITION_LEFT)
labelXAwayFromButton
else
labelXNearButton
val labelRight = if (mLabelsPosition == LABELS_POSITION_LEFT)
labelRight = if (mLabelsPosition == LABELS_POSITION_LEFT)
labelXNearButton
else
labelXAwayFromButton
val labelTop = childY - mLabelsVerticalOffset + (fab.getMeasuredHeight()
- label.getMeasuredHeight()) / 2
labelTop = childY - mLabelsVerticalOffset + (fab.getMeasuredHeight() - label.getMeasuredHeight()) / 2
}
label.layout(labelLeft, labelTop, labelRight, labelTop + label.getMeasuredHeight())
@ -667,7 +693,6 @@ class FloatingActionMenu @JvmOverloads constructor(
fab = child as FloatingActionButton
if (fab === mMenuButton) continue
// only child buttons and imageToggle on menuBtn.
child.layout(
childX, childY, childX + child.getMeasuredWidth(),
childY + child.getMeasuredHeight()
@ -681,6 +706,16 @@ class FloatingActionMenu @JvmOverloads constructor(
if (child !== mImageToggle) label = fab.getTag(R.id.fab_label) as View?
if (label != null) {
val labelLeft: Int
val labelRight: Int
val labelTop: Int
// 💡 [수정] Center 옵션일 경우 드래그 중에도 중앙으로 정렬
if (mLabelsPosition == LABELS_POSITION_CENTER) {
labelLeft = childX + (fab.getMeasuredWidth() - label.getMeasuredWidth()) / 2
labelRight = labelLeft + label.getMeasuredWidth()
labelTop = childY + (fab.getMeasuredHeight() - label.getMeasuredHeight()) / 2
} else {
val labelsOffset =
(if (mUsingMenuLabel) mMaxButtonWidth / 2 else fab.getMeasuredWidth() / 2) + mLabelsMargin
val labelXNearButton = if (mLabelsPosition == LABELS_POSITION_LEFT)
@ -693,18 +728,18 @@ class FloatingActionMenu @JvmOverloads constructor(
else
labelXNearButton + label.getMeasuredWidth()
val labelLeft = if (mLabelsPosition == LABELS_POSITION_LEFT)
labelLeft = if (mLabelsPosition == LABELS_POSITION_LEFT)
labelXAwayFromButton
else
labelXNearButton
val labelRight = if (mLabelsPosition == LABELS_POSITION_LEFT)
labelRight = if (mLabelsPosition == LABELS_POSITION_LEFT)
labelXNearButton
else
labelXAwayFromButton
val labelTop = childY - mLabelsVerticalOffset + (fab.getMeasuredHeight()
- label.getMeasuredHeight()) / 2
labelTop = childY - mLabelsVerticalOffset + (fab.getMeasuredHeight() - label.getMeasuredHeight()) / 2
}
label.layout(
labelLeft,
@ -738,6 +773,17 @@ class FloatingActionMenu @JvmOverloads constructor(
label.setTextAppearance(getContext(), mLabelsStyle)
label.setShowShadow(false)
label.setUsingStyle(true)
} else {
// 💡 [수정] Center 옵션일 경우 배경 및 테두리 효과를 제거하여 내장 텍스트처럼 보이게 연출
if (mLabelsPosition == LABELS_POSITION_CENTER) {
label.setColors(Color.TRANSPARENT, Color.TRANSPARENT, Color.TRANSPARENT)
label.setShowShadow(false)
label.setCornerRadius(0)
label.updateBackground()
label.setTextSize(TypedValue.COMPLEX_UNIT_PX, mLabelsTextSize)
label.setTextColor(mLabelsTextColor)
label.setPadding(0, 0, 0, 0)
} else {
label.setColors(mLabelsColorNormal, mLabelsColorPressed, mLabelsColorRipple)
label.setShowShadow(mLabelsShowShadow)
@ -764,6 +810,7 @@ class FloatingActionMenu @JvmOverloads constructor(
mLabelsPaddingLeft,
mLabelsPaddingTop
)
}
if (mLabelsMaxLines < 0 || mLabelsSingleLine) {
label.setSingleLine(mLabelsSingleLine)
@ -1224,7 +1271,9 @@ class FloatingActionMenu @JvmOverloads constructor(
private const val OPEN_UP = 0
private const val OPEN_DOWN = 1
private const val LABELS_POSITION_LEFT = 0
private const val LABELS_POSITION_RIGHT = 1
// 💡 [수정] 상수 공개 선언 및 CENTER 속성(2) 추가
const val LABELS_POSITION_LEFT = 0
const val LABELS_POSITION_RIGHT = 1
const val LABELS_POSITION_CENTER = 2
}
}

View File

@ -109,12 +109,16 @@ object TaskAggregator {
// [일괄 저장]
if (allFeeds.isNotEmpty()) {
try {
WorkersDb.getRealm().write {
// 1. 새 데이터 저장
allFeeds.forEach { feed ->
copyToRealm(feed, UpdatePolicy.ALL)
try { copyToRealm(feed, UpdatePolicy.ERROR) }catch (e: Exception){}
}
}
}catch (e: Exception){}
}
Blog.LOGE("NewsFeed Aggregation finished in ${System.currentTimeMillis() - startTime}ms. Items: ${allFeeds.size}")

View File

@ -28,6 +28,7 @@ import bums.lunatic.launcher.utils.Blog
import com.frostwire.jlibtorrent.swig.error_code
import com.frostwire.jlibtorrent.swig.libtorrent
import com.frostwire.jlibtorrent.swig.settings_pack
import com.frostwire.jlibtorrent.swig.torrent_status
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable.isActive
@ -41,8 +42,10 @@ import kotlinx.coroutines.launch
data class TorrentTask(
val infoHash: String,
val name: String,
val progress: Float, // 0.0 ~ 100.0
val isPaused: Boolean
val progress: Float,
val isPaused: Boolean,
val isQueued: Boolean, // 대기열 여부
val stateText: String
)
class TorrentService : Service() {
@ -87,6 +90,16 @@ class TorrentService : Service() {
} else if (hasActiveTorrents && taskCount == 0) {
// 활성화된 적이 있었는데 0개가 되었다면 (모두 완료되어 이동되었거나 삭제됨)
println("TorrentService: 모든 작업이 완료되어 서비스를 자동 종료합니다.")
try {
if (tempDir.exists()) {
tempDir.listFiles()?.forEach { it.deleteRecursively() }
println("TorrentService: 임시 다운로드 폴더 정리를 완료했습니다.")
}
} catch (e: Exception) {
e.printStackTrace()
}
stopForeground(true) // 💡 상단바 알림 즉시 제거
stopSelf() // 💡 서비스 스스로 종료 (메모리 해제)
break // 무한 루프 탈출
@ -103,6 +116,30 @@ class TorrentService : Service() {
if (!handle.isValid) continue
val status = handle.status()
val state = status.state()
// 1. 현재 다운로드 대기 중(Queued)인지 확인
// 'auto_managed'이면서, 실제 'downloading' 상태가 아닌 경우를 체크합니다.
val isQueued = status.flags().and_(libtorrent.getAuto_managed()).nonZero() &&
(state.swig() == torrent_status.state_t.downloading_metadata.swigValue() ||
state.swig() == torrent_status.state_t.checking_files.swigValue() ||
state.swig() == torrent_status.state_t.checking_resume_data.swigValue())
// 2. 더 확실한 방법 (상태값이 'queued_for_checking' 등인 경우 포함)
// 실제 데이터 전송이 일어나지 않고 대기 순번을 기다리는 상태인지 확인
val isDownloading = (state.swig() == torrent_status.state_t.downloading.swigValue())
val isFinished = status.isFinished
val isPaused = status.flags().and_(libtorrent.getPaused()).nonZero()
val stateText = when {
status.flags().and_(libtorrent.getPaused()).nonZero() -> "일시정지"
state.swig() == torrent_status.state_t.checking_files.swigValue() -> "파일 검사 중"
state.swig() == torrent_status.state_t.downloading_metadata.swigValue() -> "메타데이터 수신 중"
state.swig() == torrent_status.state_t.downloading.swigValue() -> "다운로드 중"
state.swig() == torrent_status.state_t.finished.swigValue() || state.swig() == torrent_status.state_t.seeding.swigValue() -> "완료"
else -> "대기 중" // activeDownloads 제한에 걸려 대기하는 경우 여기로 들어옵니다.
}
// "일시정지도 아니고, 완료도 아닌데, 현재 다운로드 중도 아니면" -> 대기열 상태로 간주
val realIsQueued = !isPaused && !isFinished && !isDownloading
val hashStr = status.infoHash().toString()
var rawName = status.name()
@ -133,7 +170,9 @@ class TorrentService : Service() {
infoHash = hashStr,
name = displayName,
progress = status.progress() * 100f,
isPaused = status.flags().and_(com.frostwire.jlibtorrent.swig.libtorrent.getPaused()).nonZero()
isPaused = status.flags().and_(com.frostwire.jlibtorrent.swig.libtorrent.getPaused()).nonZero(),
isQueued = isQueued,
stateText = stateText
)
)
}
@ -199,12 +238,24 @@ class TorrentService : Service() {
sp.setBoolean(settings_pack.bool_types.enable_dht.swigValue(), true) // DHT 활성화
// sp.setBoolean(settings_pack.bool_types.enable_pex.swigValue(), true) // Peer Exchange (피어 교환)
sp.setBoolean(settings_pack.bool_types.enable_lsd.swigValue(), true) // 로컬 네트워크 탐색
sp.setBoolean(settings_pack.bool_types.dont_count_slow_torrents.swigValue(), true)
// 2. '느리다'고 판단할 다운로드 속도 기준 (예: 10KB/s 미만)
sp.setInteger(settings_pack.int_types.inactive_down_rate.swigValue(), 10 * 1024)
// 3. '느리다'고 판단할 업로드 속도 기준 (예: 5KB/s 미만)
sp.setInteger(settings_pack.int_types.inactive_up_rate.swigValue(), 5 * 1024)
// 4. 이 느린 속도가 몇 초간 지속될 때 대기열로 미룰 것인지 판단 (예: 20초)
sp.setInteger(settings_pack.int_types.inactivity_timeout.swigValue(), 20)
// [3] 얼마나 오래 느려야 대기열로 보낼지 결정 (초 단위)
sp.setInteger(settings_pack.int_types.auto_manage_interval.swigValue(), 20)
// [3] 모바일 맞춤형 연결 최적화
// 안드로이드는 데스크탑처럼 수천 개를 뚫으면 공유기가 뻗거나 앱 OOM이 발생할 수 있습니다.
sp.connectionsLimit(400) // 글로벌 최대 연결 수 (기본값 보통 200)
sp.activeDownloads(3) // 동시에 속도를 끌어올릴 다운로드 개수 (너무 많으면 속도가 분산됨)
sp.activeLimit(5) // 활성 상태(업/다운 포함) 유지 최대 개수
sp.activeDownloads(4) // 동시에 속도를 끌어올릴 다운로드 개수 (너무 많으면 속도가 분산됨)
sp.activeLimit(6) // 활성 상태(업/다운 포함) 유지 최대 개수
sp.activeSeeds(2)
// 세팅을 세션에 반영
session.applySettings(sp)
@ -254,15 +305,14 @@ class TorrentService : Service() {
println("TorrentService: 마그넷 파싱 에러 - ${error.message()} / URI: $magnetUri")
return
}
swigParams.max_connections = 150
swigParams.flags = swigParams.flags.or_(libtorrent.getAuto_managed())
swigParams.max_connections = Int.MAX_VALUE
// 다운로드/업로드 제한 해제 (기본값이 -1이긴 하나 명시적으로 박아줌)
swigParams.download_limit = -1
swigParams.upload_limit = -1
// 참고: 만약 "순차 다운로드(동영상 스트리밍용)"가 필요하다면 아래 주석을 푸세요.
// 단, 전체 다운로드 완료 속도는 일반 다운로드보다 느려집니다.
// swigParams.flags = swigParams.flags.or_(libtorrent.getSequential_download())
swigParams.setSave_path(tempDir.absolutePath)
session.swig().async_add_torrent(swigParams)
@ -289,7 +339,10 @@ class TorrentService : Service() {
/** 제어 기능 */
fun pauseTorrent(infoHash: String) {
session.find(Sha1Hash(infoHash))?.pause()
session.find(Sha1Hash(infoHash))?.let {
it.pause()
it.queuePositionBottom()
}
}

View File

@ -125,23 +125,22 @@
<LinearLayout
android:id="@+id/quickSearch"
android:layout_width="wrap_content"
android:layout_height="30dp"
android:layout_height="32dp"
android:orientation="horizontal"
android:background="@color/black"
android:gravity="center_vertical"
>
<androidx.appcompat.widget.AppCompatSpinner
android:paddingLeft="5dp"
android:paddingRight="5dp"
style="@style/SearchAccs"
android:layout_marginLeft="6dp"
android:layout_marginRight="6dp"
android:layout_marginTop="12dp"
android:layout_marginBottom="12dp"
android:paddingRight="5dp"
android:paddingLeft="5dp"
android:id="@+id/categorySpinner"
android:layout_width="wrap_content"
android:layout_height="30dp"
android:spinnerMode="dropdown"
android:background="@drawable/base_bg"/>
android:layout_height="match_parent"
android:background="@color/black"
android:spinnerMode="dropdown" />
<TextView
app:autoSizeTextType="uniform"
style="@style/SearchAccs"

View File

@ -0,0 +1,327 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true"
android:background="#E6121212">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="32dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginBottom="24dp"
android:text="System Status"
android:textColor="#FFFFFF"
android:textSize="26sp"
android:textStyle="bold" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:background="#1AFFFFFF"
android:padding="12dp"
android:layout_marginBottom="24dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Quick Controls"
android:textColor="#AAAAAA"
android:textSize="12sp"
android:layout_marginBottom="8dp"/>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="12dp"
android:weightSum="3">
<TextView
android:id="@+id/btnQuickWifi"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:background="?attr/selectableItemBackground"
android:gravity="center"
android:padding="8dp"
android:text="📶 Wi-Fi"
android:textColor="#FFFFFF"
android:textStyle="bold" />
<TextView
android:id="@+id/btnQuickBt"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:background="?attr/selectableItemBackground"
android:gravity="center"
android:padding="8dp"
android:text="🛜 Bluetooth"
android:textColor="#FFFFFF"
android:textStyle="bold" />
<TextView
android:id="@+id/btnQuickRinger"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:background="?attr/selectableItemBackground"
android:gravity="center"
android:padding="8dp"
android:text="🔔 소리"
android:textColor="#FFFFFF"
android:textStyle="bold" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="☀️"
android:textSize="18sp"/>
<SeekBar
android:id="@+id/seekBrightness"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:max="255"
android:progressTint="#FFEB3B"/>
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:layout_marginTop="8dp"
android:orientation="horizontal">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="🎵"
android:textSize="18sp"/>
<SeekBar
android:id="@+id/seekVolume"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:progressTint="#03A9F4"/>
</LinearLayout>
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginBottom="24dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Memory (RAM) - 터치 시 정리"
android:textColor="#AAAAAA"
android:textSize="14sp" />
<TextView
android:id="@+id/textRam"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="RAM: 계산 중..."
android:textColor="#FFFFFF"
android:textSize="16sp"
android:textStyle="bold" />
<ProgressBar
android:id="@+id/progressRam"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="match_parent"
android:layout_height="12dp"
android:layout_marginTop="8dp"
android:max="100"
android:progress="0"
android:progressTint="#4CAF50" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginBottom="24dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Internal Storage - 터치 시 관리"
android:textColor="#AAAAAA"
android:textSize="14sp" />
<TextView
android:id="@+id/textStorage"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="Storage: 계산 중..."
android:textColor="#FFFFFF"
android:textSize="16sp"
android:textStyle="bold" />
<ProgressBar
android:id="@+id/progressStorage"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="match_parent"
android:layout_height="12dp"
android:layout_marginTop="8dp"
android:max="100"
android:progress="0"
android:progressTint="#2196F3" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginBottom="24dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Battery &amp; Thermal"
android:textColor="#AAAAAA"
android:textSize="14sp" />
<TextView
android:id="@+id/textChargingStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:text="Status: 상태 확인 중..."
android:textColor="#888888"
android:textSize="12sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:orientation="horizontal">
<TextView
android:id="@+id/textBattery"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Battery: 계산 중..."
android:textColor="#FFFFFF"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:id="@+id/textTemperature"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Temp: 0.0°C"
android:textColor="#FF9800"
android:textSize="16sp"
android:textStyle="bold" />
</LinearLayout>
<ProgressBar
android:id="@+id/progressBattery"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="match_parent"
android:layout_height="12dp"
android:layout_marginTop="8dp"
android:max="100"
android:progress="0"
android:progressTint="#FFC107" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginBottom="24dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Network &amp; Connectivity"
android:textColor="#AAAAAA"
android:textSize="14sp" />
<TextView
android:id="@+id/textNetworkState"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="Net: 확인 중..."
android:textColor="#2196F3"
android:textSize="13sp"
android:textStyle="bold" />
<TextView
android:id="@+id/textBluetoothState"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:text="BT: 확인 중..."
android:textColor="#03A9F4"
android:textSize="13sp"
android:textStyle="bold" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="horizontal">
<TextView
android:id="@+id/textDownload"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="↓ DL: 0 KB/s"
android:textColor="#4CAF50"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:id="@+id/textUpload"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="↑ UL: 0 KB/s"
android:textColor="#F44336"
android:textSize="16sp"
android:textStyle="bold" />
</LinearLayout>
<TextView
android:id="@+id/textMonthlyUsage"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:background="?attr/selectableItemBackground"
android:paddingVertical="4dp"
android:text="Monthly Usage: 권한 필요 (여기를 클릭)"
android:textColor="#FFFFFF"
android:textSize="13sp"
android:textStyle="bold" />
<LinearLayout
android:id="@+id/layoutTopApps"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="vertical"
android:visibility="gone" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@+id/textUptime"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="System Uptime: 00:00:00"
android:textColor="#888888"
android:textSize="12sp" />
</LinearLayout>
</LinearLayout>
</ScrollView>

View File

@ -11,13 +11,13 @@
<TextView
android:id="@+id/tvName"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_height="match_parent"
tools:text="Ubuntu-22.04-desktop-amd64.iso"
android:textSize="14sp"
android:textColor="@color/white"
android:textStyle="bold"
android:maxLines="1"
android:ellipsize="middle"
android:ellipsize="marquee"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toStartOf="@+id/btnPauseResume"

View File

@ -89,6 +89,7 @@
app:menu_colorNormal="#80FF0000"
app:menu_fab_size="mini"
app:menu_icon="@drawable/ic_add"
app:menu_labels_position="center"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintRight_toRightOf="parent"
android:layout_height="match_parent"
@ -102,6 +103,7 @@
android:onClick="floatClick"
android:layout_width="wrap_content"
android:layout_height="20dp"/>
<bums.lunatic.launcher.view.FloatingActionButton
app:fab_label="booktoki"
android:id="@+id/books"
@ -160,6 +162,15 @@
android:layout_width="wrap_content"
android:layout_height="20dp"/>
<bums.lunatic.launcher.view.FloatingActionButton
app:fab_label="I"
android:id="@+id/btn_i"
app:fab_showShadow="true"
app:fab_size="mini"
android:onClick="floatClick"
android:layout_width="wrap_content"
android:layout_height="20dp"/>
<bums.lunatic.launcher.view.FloatingActionButton
app:fab_label="torrent"
android:id="@+id/btn_torrent"
@ -168,7 +179,14 @@
android:onClick="floatClick"
android:layout_width="wrap_content"
android:layout_height="20dp"/>
<bums.lunatic.launcher.view.FloatingActionButton
app:fab_label="system"
android:id="@+id/btn_info"
app:fab_showShadow="true"
app:fab_size="mini"
android:onClick="floatClick"
android:layout_width="wrap_content"
android:layout_height="20dp"/>
<bums.lunatic.launcher.view.FloatingActionButton
app:fab_label="close"
android:id="@+id/close"

View File

@ -1,8 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@android:id/text1"
style="@style/SearchAccs"
android:layout_margin="10dp"
android:padding="10dp"
android:gravity="center"
android:textColor="@color/bottom_option"
android:layout_height="match_parent"
android:layout_width="match_parent"
android:background="@color/black"
android:ellipsize="marquee"
android:singleLine="true" />

View File

@ -29,6 +29,7 @@
<enum name="normal" value="0" />
<enum name="mini" value="1" />
</attr>
<attr name="fab_showAnimation" format="reference" />
<attr name="fab_hideAnimation" format="reference" />
<attr name="fab_label" format="string" />
@ -62,6 +63,7 @@
<attr name="menu_labels_position" format="enum">
<enum name="left" value="0" />
<enum name="right" value="1" />
<enum name="center" value="2" />
</attr>
<attr name="menu_icon" format="reference" />
<attr name="menu_animationDelayPerItem" format="integer" />

View File

@ -44,7 +44,7 @@
<item name="android:layout_width">wrap_content</item>
<item name="android:layout_height">35dp</item>
<item name="android:gravity">center</item>
<item name="android:textColor">@color/tabs_black</item>
<item name="android:textColor">@color/bottom_option</item>
<item name="android:visibility">visible</item>
</style>
<style name="asdda" parent="Widget.Material3.Button.OutlinedButton">