From e9e585bad0991694e4acfb38696630cab722942d Mon Sep 17 00:00:00 2001 From: lunaticbum Date: Wed, 1 Apr 2026 17:59:30 +0900 Subject: [PATCH] ... --- .../lunatic/launcher/common/CommonActivity.kt | 1 + .../launcher/helpers/ForeGroundService.kt | 187 ++++++++++++- .../lunatic/launcher/helpers/PrefHelper.kt | 2 + .../lunatic/launcher/home/LearningFragment.kt | 258 ++++++++++++++++++ .../lunatic/launcher/home/NeoRssActivity.kt | 1 + .../lunatic/launcher/model/WallContent.kt | 50 ++++ .../lunatic/launcher/settings/childs/Misc.kt | 186 +++++++++++++ .../launcher/wall/MyWallpaperService.kt | 104 ++++--- .../launcher/workers/TorrentManager.kt | 2 +- .../lunatic/launcher/workers/WorkersDb.kt | 35 +++ app/src/main/res/layout/fragment_learning.xml | 38 +++ app/src/main/res/layout/rss_activity.xml | 6 + .../res/layout/settings_privit_service.xml | 21 ++ 13 files changed, 857 insertions(+), 34 deletions(-) create mode 100644 app/src/main/kotlin/bums/lunatic/launcher/home/LearningFragment.kt create mode 100644 app/src/main/kotlin/bums/lunatic/launcher/model/WallContent.kt create mode 100644 app/src/main/res/layout/fragment_learning.xml diff --git a/app/src/main/kotlin/bums/lunatic/launcher/common/CommonActivity.kt b/app/src/main/kotlin/bums/lunatic/launcher/common/CommonActivity.kt index cea5db74..1d471d02 100644 --- a/app/src/main/kotlin/bums/lunatic/launcher/common/CommonActivity.kt +++ b/app/src/main/kotlin/bums/lunatic/launcher/common/CommonActivity.kt @@ -9,6 +9,7 @@ import androidx.annotation.RequiresApi import androidx.appcompat.app.AppCompatActivity import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsControllerCompat +import androidx.fragment.app.Fragment import bums.lunatic.launcher.apps.SearchMenu import bums.lunatic.launcher.helpers.PrefBoolean import bums.lunatic.launcher.utils.Blog diff --git a/app/src/main/kotlin/bums/lunatic/launcher/helpers/ForeGroundService.kt b/app/src/main/kotlin/bums/lunatic/launcher/helpers/ForeGroundService.kt index 603d45a5..916f5c41 100644 --- a/app/src/main/kotlin/bums/lunatic/launcher/helpers/ForeGroundService.kt +++ b/app/src/main/kotlin/bums/lunatic/launcher/helpers/ForeGroundService.kt @@ -60,10 +60,22 @@ import android.os.Vibrator import android.view.KeyEvent import androidx.annotation.RequiresPermission import androidx.core.content.ContentProviderCompat.requireContext +import androidx.work.Constraints +import androidx.work.NetworkType import androidx.work.workDataOf import bums.lunatic.launcher.home.GeckoWeb.Companion.currentCookieString import bums.lunatic.launcher.home.GeckoWeb.Companion.currentCookieUrlString +import bums.lunatic.launcher.model.WallContentGroup +import bums.lunatic.launcher.workers.WorkersDb +import com.google.common.reflect.TypeToken +import com.google.gson.Gson import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import okhttp3.MediaType +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import java.util.Calendar class WallpaperAutoChangeWorker(private val context: Context, params: WorkerParameters) : CoroutineWorker(context, params) { override suspend fun doWork(): Result { @@ -159,6 +171,155 @@ class AggregatedNewsWorker(private val context: Context, params: WorkerParameter } } + + +class WallContentsWorker(private val context: Context, params: WorkerParameters) : CoroutineWorker(context, params) { + + // 1. 필요한 Gemini 응답 데이터 클래스 추가 + data class GeminiResponse(val candidates: List) + data class Candidate(val content: Content) + data class Content(val parts: List) + data class Part(val text: String) + + // 불필요한 최상단 따옴표를 제거한 깔끔한 프롬프트 + + + override suspend fun doWork(): Result { + return try { + refreshGeminiContent() + Result.success() + } catch (e: Exception) { + Blog.LOGE("WallContentsWorker Failed", e) + Result.retry() // 일시적 오류일 수 있으므로 재시도 반환 + } + } + + private fun getTimeBasedContext(): String { + val hour = Calendar.getInstance().get(Calendar.HOUR_OF_DAY) + + return when (hour) { + in 6..10 -> "지금은 아침 시간이야. 카페에서 커피/브런치를 주문하거나, 상쾌하게 아침 인사를 나누는 상황에 맞는 가벼운 스몰토크를 알려줘." + in 11..16 -> "지금은 낮 시간이야. 관광지에서 길을 묻거나, 점심 식사 주문, 상점에서 물건을 구매할 때 쓰는 활동적인 실생활 표현을 알려줘." + in 17..21 -> "지금은 저녁 시간이야. 분위기 있는 식당에서 저녁을 먹거나, 펍에서 맥주 한잔하며 현지인과 가볍게 나누는 스몰토크를 알려줘." + else -> "지금은 늦은 밤이야. 숙소(호텔) 프론트에 문의하거나, 편의점에서 야식을 사고, 내일 일정을 묻는 등 밤 시간에 필요한 실용적인 표현을 알려줘." + } + } + + private suspend fun refreshGeminiContent() { + val apiKey = PrefString.geminiApiKey.get("") // 설정에서 가져온 키 + if (apiKey.isEmpty()) { + Blog.LOGE("Gemini API Key is empty.") + return + } + + val resultJson = callGeminiApi(apiKey, getDynamicPrompt()) ?: return + Blog.LOGE("resultJson >>> $resultJson") + val newGroups = parseGeminiResponse(resultJson) ?: return + Blog.LOGE("newGroups.size ${newGroups.size}") + WorkersDb.insertExpressions(newGroups) + + } + + private fun getDynamicPrompt(): String { + val myStyle = PrefString.travelStyle.get("주로 현지 로컬 맛집을 찾아다니고, 가벼운 스몰토크를 즐기는 편이야. 흡연 및 음주를 즐기는 편이고, 여행은 항상 부인과 같이 다녀") + val timeContext = getTimeBasedContext() + + return """ + 너는 여행 및 외국어 학습 전문가야. 아래의 [나의 스타일]과 [현재 상황]을 반영해서, 당장 써먹을 수 있는 유용한 실용 회화 표현 12가지를 선정해줘. + + [나의 스타일]: $myStyle + [현재 상황]: $timeContext + + 각 표현에 대해 영어(EN), 스페인어(ES), 일본어(JA), 중국어(ZH) 네 가지 버전을 제공해. + + 중요 규칙: + - 모든 외국어 문장의 'pron'에는 최대한 정확한 한국어 발음을 적어줘. + - 일본어(JA)는 한자와 가나 혼용, 중국어(ZH)는 간체자를 쓰고 'pron'에 병음과 한글 발음을 적어줘. + - 각 번역본마다 그 문장에서 가장 중요한 '핵심 단어나 숙어' 2~3개를 뽑아서 `words` 배열에 원단어(word)와 뜻(meaning)으로 정리해줘. + + 반드시 아래 JSON 배열 형식으로만 응답해: + [ + { + "topic": "주제", + "translations": [ + { + "lang": "EN", + "main": "Sentence", + "sub": "한국어 뜻", + "pron": "한글 발음", + "words": [ + {"word": "단어1", "meaning": "뜻1", "pron": "단어1 발음"}, + {"word": "단어2", "meaning": "뜻2", "pron": "단어2 발음"} + ] + }, + ... (ES, JA, ZH도 동일한 구조) + ] + } + ] + // (이 형식으로 총 12개 세트를 제공할 것) + """.trimIndent() + } + + private suspend fun callGeminiApi(apiKey: String, prompt: String): String? { + return withContext(Dispatchers.IO) { + val client = OkHttpClient.Builder() + .connectTimeout(60, TimeUnit.SECONDS) // 서버 연결 대기 시간 + .readTimeout(60, TimeUnit.SECONDS) // 데이터 전달받는 시간 (중요!) + .writeTimeout(60, TimeUnit.SECONDS) // 데이터 전송 시간 + .build() + val url = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=$apiKey" + + // 3. Gson을 사용하여 JSON 구조를 안전하게 생성 (따옴표 깨짐 방지) + val requestMap = mapOf( + "contents" to listOf( + mapOf("parts" to listOf(mapOf("text" to prompt))) + ), + "generationConfig" to mapOf( + "responseMimeType" to "application/json" + ) + ) + + val jsonRequest = Gson().toJson(requestMap) + + val body = jsonRequest.toRequestBody("application/json; charset=utf-8".toMediaTypeOrNull()) + val request = Request.Builder() + .url(url) + .post(body) + .build() + + try { + val response = client.newCall(request).execute() + if (response.isSuccessful) { + response.body?.string() + } else { + Blog.LOGE("Gemini API Error: ${response.code} ${response.message}") + null + } + } catch (e: Exception) { + e.printStackTrace() + null + } + } + } + + private fun parseGeminiResponse(jsonString: String): List? { + return try { + val gson = Gson() + val fullResponse = gson.fromJson(jsonString, GeminiResponse::class.java) + val innerJson = fullResponse.candidates[0].content.parts[0].text + + val groupType = object : TypeToken>() {}.type + gson.fromJson(innerJson, groupType) + } catch (e: Exception) { + Blog.LOGE("Parsing Error: ${e.message}") + null + } + } + +} + + + 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 @@ -473,6 +634,24 @@ class ForeGroundService : Service() { // 기존의 수많은 enqueue 코드를 이 두 개로 대체 workManager.enqueueUniquePeriodicWork("AggregatedSystemWork", ExistingPeriodicWorkPolicy.KEEP, systemRequest) workManager.enqueueUniquePeriodicWork("AggregatedNewsWork", ExistingPeriodicWorkPolicy.KEEP, newsRequest) + + + val wallpaperRequest = PeriodicWorkRequestBuilder( + 120, java.util.concurrent.TimeUnit.MINUTES // 💡 2시간 간격으로 설정 + ) + .setConstraints( + Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) // 네트워크 연결 시에만 + .build() + ) + .build() + + workManager.enqueueUniquePeriodicWork( + "WallContentsWork", + ExistingPeriodicWorkPolicy.KEEP, + wallpaperRequest + ) + } @@ -648,4 +827,10 @@ class ForeGroundService : Service() { } } -} \ No newline at end of file + +} + +data class GeminiResponse(val candidates: List) +data class Candidate(val content: Content) +data class Content(val parts: List) +data class Part(val text: String) diff --git a/app/src/main/kotlin/bums/lunatic/launcher/helpers/PrefHelper.kt b/app/src/main/kotlin/bums/lunatic/launcher/helpers/PrefHelper.kt index 78ddbaac..3a11fb5d 100644 --- a/app/src/main/kotlin/bums/lunatic/launcher/helpers/PrefHelper.kt +++ b/app/src/main/kotlin/bums/lunatic/launcher/helpers/PrefHelper.kt @@ -10,10 +10,12 @@ enum class PrefString : PrefKey { defaultTimeFormat, defaultDateFormat, weatherApiKey, + geminiApiKey, locationApi, telegramBotApi, telegramMyId, telegramSendTarget, + travelStyle, carName; override fun set(value: String) {PrefHelper.putString(this.name, value)} override fun get(def : String?) : String = PrefHelper.getString(this.name, def as? String ?: "") ?: "" diff --git a/app/src/main/kotlin/bums/lunatic/launcher/home/LearningFragment.kt b/app/src/main/kotlin/bums/lunatic/launcher/home/LearningFragment.kt new file mode 100644 index 00000000..1fa587e2 --- /dev/null +++ b/app/src/main/kotlin/bums/lunatic/launcher/home/LearningFragment.kt @@ -0,0 +1,258 @@ +package bums.lunatic.launcher.home + +import android.graphics.Color +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import android.widget.LinearLayout +import android.widget.TextView +import androidx.fragment.app.Fragment +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import bums.lunatic.launcher.R +import bums.lunatic.launcher.model.ExpressionItem +import bums.lunatic.launcher.workers.WorkersDb +import com.google.android.material.chip.Chip +import com.google.android.material.chip.ChipGroup +import io.realm.kotlin.ext.query + +class LearningFragment : Fragment() { + + private lateinit var recyclerExpressions: RecyclerView + private lateinit var layoutTopics: ViewGroup + private lateinit var layoutLanguages: ViewGroup + + private val adapter = ExpressionAdapter() + private var currentTopic: String = "" + private var currentLang: String = "EN" // 기본 영어 + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + val view = inflater.inflate(R.layout.fragment_learning, container, false) + + recyclerExpressions = view.findViewById(R.id.recycler_expressions) + layoutTopics = view.findViewById(R.id.layout_topics) + layoutLanguages = view.findViewById(R.id.layout_languages) + + recyclerExpressions.layoutManager = LinearLayoutManager(requireContext()) + recyclerExpressions.adapter = adapter + + setupFilters() + loadExpressions() + + return view + } + + private fun setupFilters() { + val realm = WorkersDb.getRealm() + + // 1. 언어 필터 세팅 + val langs = listOf( + "EN" to "영어", + "ES" to "스페인어", + "JA" to "일본어", + "ZH" to "중국어" // 추가된 부분! + ) + layoutLanguages.removeAllViews() + langs.forEach { (code, name) -> + val btn = Button(requireContext()).apply { + text = name + setBackgroundColor(if (currentLang == code) Color.DKGRAY else Color.TRANSPARENT) + setTextColor(if (currentLang == code) Color.WHITE else Color.LTGRAY) + setOnClickListener { + currentLang = code + setupFilters() // 색상 갱신을 위해 재호출 + loadExpressions() + } + } + layoutLanguages.addView(btn) + } + + // 2. 주제(Topic) 필터 세팅 (DB에서 고유값 가져오기) + // 참고: Realm Kotlin은 distinct를 바로 지원하진 않으므로 전체를 가져와서 Set으로 필터링합니다. + val allItems = realm.query().find() + val topics = allItems.map { it.topic }.distinct() + + layoutTopics.removeAllViews() + + // '전체' 보기 버튼 + val allBtn = Button(requireContext()).apply { + text = "전체" + setBackgroundColor(if (currentTopic.isEmpty()) Color.DKGRAY else Color.TRANSPARENT) + setTextColor(if (currentTopic.isEmpty()) Color.WHITE else Color.LTGRAY) + setOnClickListener { + currentTopic = "" + setupFilters() + loadExpressions() + } + } + layoutTopics.addView(allBtn) + + topics.forEach { topic -> + val btn = Button(requireContext()).apply { + text = topic + setBackgroundColor(if (currentTopic == topic) Color.DKGRAY else Color.TRANSPARENT) + setTextColor(if (currentTopic == topic) Color.WHITE else Color.LTGRAY) + setOnClickListener { + currentTopic = topic + setupFilters() + loadExpressions() + } + } + layoutTopics.addView(btn) + } + } + + private fun loadExpressions() { + val realm = WorkersDb.getRealm() + + var query = realm.query("lang == $0", currentLang) + if (currentTopic.isNotEmpty()) { + query = query.query("topic == $0", currentTopic) + } + + // 최신순으로 정렬 + val results = query.sort("timestamp", io.realm.kotlin.query.Sort.DESCENDING).find() + adapter.submitList(results.toList()) + } + + // --- 내부 RecyclerView 어댑터 --- + inner class ExpressionAdapter : RecyclerView.Adapter() { + private var items: List = emptyList() + + fun submitList(newItems: List) { + items = newItems + notifyDataSetChanged() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + // 1. 전체 카드 컨테이너 + val view = LinearLayout(parent.context).apply { + orientation = LinearLayout.VERTICAL + setPadding(40, 40, 40, 40) + layoutParams = ViewGroup.MarginLayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ).apply { setMargins(0, 0, 0, 24) } + // 모서리가 둥근 반투명 배경 효과 (간단히 Color 설정) + setBackgroundColor(Color.parseColor("#22FFFFFF")) + } + + // 2. 문장 텍스트 + val mainText = TextView(parent.context).apply { + textSize = 22f + setTextColor(Color.WHITE) + setTypeface(null, android.graphics.Typeface.BOLD) + } + + // 3. 발음 텍스트 + val pronText = TextView(parent.context).apply { + textSize = 15f + setTextColor(Color.parseColor("#80DEEA")) // Cyan 계열 + setPadding(0, 12, 0, 0) + } + + // 4. 뜻 텍스트 + val subText = TextView(parent.context).apply { + textSize = 14f + setTextColor(Color.LTGRAY) + setPadding(0, 8, 0, 24) + } + + // 💡 5. 단어들을 담을 ChipGroup (자동 줄바꿈 지원) + val chipGroup = ChipGroup(parent.context).apply { + layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ) + isSingleLine = false // 공간이 부족하면 아래로 자동 줄바꿈 + chipSpacingHorizontal = 16 + chipSpacingVertical = 16 + } + + view.addView(mainText) + view.addView(pronText) + view.addView(subText) + view.addView(chipGroup) // ChipGroup 추가 + + return ViewHolder(view, mainText, pronText, subText, chipGroup) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + val item = items[position] + + // 언어별 폰트 느낌 살리기 (옵션) + when (item.lang) { + "ZH" -> holder.mainText.textLocale = java.util.Locale.SIMPLIFIED_CHINESE + "JA" -> holder.mainText.textLocale = java.util.Locale.JAPAN + else -> holder.mainText.textLocale = java.util.Locale.US + } + + holder.mainText.text = item.mainText + holder.pronText.text = "[${item.pronunciation}]" + holder.subText.text = item.subText + + // 💡 단어 Chip 생성 로직 + holder.chipGroup.removeAllViews() // 재사용 방지를 위해 기존 칩 지우기 + + item.words.forEach { wordItem -> + val chip = Chip(holder.itemView.context).apply { + text = wordItem.word + isCheckable = false + isClickable = true + setChipBackgroundColorResource(android.R.color.darker_gray) + setTextColor(Color.WHITE) + + var isShowingMeaning = false + setOnClickListener { + isShowingMeaning = !isShowingMeaning + if (isShowingMeaning) { + // 💡 터치 시: 단어 [발음] : 뜻 형태로 보여줌 + text = "${wordItem.word} [${wordItem.pronunciation}] : ${wordItem.meaning}" + setChipBackgroundColorResource(android.R.color.holo_blue_dark) + } else { + // 다시 터치 시: 원단어만 보임 + text = wordItem.word + setChipBackgroundColorResource(android.R.color.darker_gray) + } + } + setOnLongClickListener { + // 언어별 사전 URL 분기 처리 + val baseUrl = when (item.lang) { + "EN" -> "https://en.dict.naver.com/#/search?query=" + "JA" -> "https://ja.dict.naver.com/#/search?query=" + "ZH" -> "https://zh.dict.naver.com/#/search?query=" + "ES" -> "https://dict.naver.com/eskodict/#/search?query=" + else -> "https://dict.naver.com/search.dict?dicQuery=" // 예비용 통합 검색 + } + + val searchUrl = baseUrl + wordItem.word + + // 브라우저 띄우기 Intent 생성 + val intent = android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(searchUrl)) + + // 혹시 액티비티 밖에서 실행될 경우를 대비한 플래그 (어댑터 안에서는 보통 필요 없지만 안전하게 추가) + intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK) + + holder.itemView.context.startActivity(intent) + + // true를 반환해야 일반 클릭(setOnClickListener)이 동시에 실행되지 않음 + true + } + } + holder.chipGroup.addView(chip) + } + } + + override fun getItemCount() = items.size + + inner class ViewHolder( + view: View, + val mainText: TextView, + val pronText: TextView, + val subText: TextView, + val chipGroup: ChipGroup // ChipGroup 참조 추가 + ) : RecyclerView.ViewHolder(view) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/bums/lunatic/launcher/home/NeoRssActivity.kt b/app/src/main/kotlin/bums/lunatic/launcher/home/NeoRssActivity.kt index c7bea30c..c9aa1bb6 100644 --- a/app/src/main/kotlin/bums/lunatic/launcher/home/NeoRssActivity.kt +++ b/app/src/main/kotlin/bums/lunatic/launcher/home/NeoRssActivity.kt @@ -552,6 +552,7 @@ open class NeoRssActivity : CommonActivity() { R.id.btn_torrent -> TorrentListFragment() R.id.btn_info -> SystemStatusFragment() R.id.btn_completed_files -> CompletedFilesFragment() + R.id.btn_learn -> LearningFragment() R.id.close -> { finish() return diff --git a/app/src/main/kotlin/bums/lunatic/launcher/model/WallContent.kt b/app/src/main/kotlin/bums/lunatic/launcher/model/WallContent.kt new file mode 100644 index 00000000..ae80f380 --- /dev/null +++ b/app/src/main/kotlin/bums/lunatic/launcher/model/WallContent.kt @@ -0,0 +1,50 @@ +package bums.lunatic.launcher.model + +import io.realm.kotlin.ext.realmListOf +import io.realm.kotlin.types.RealmList +import io.realm.kotlin.types.RealmObject +import io.realm.kotlin.types.annotations.PrimaryKey +import org.mongodb.kbson.ObjectId +import java.util.UUID + +data class WordItem( + val word: String, + val meaning: String, + val pron: String // 💡 발음 필드 추가! +) + +data class Translation( + val lang: String, + val main: String, + val sub: String, + val pron: String, + val words: List = emptyList() // 💡 단어장 배열 추가! +) + +data class WallContentGroup( + val topic: String, + val translations: List +) + +class ExpressionWord : RealmObject { + var word: String = "" + var meaning: String = "" + var pronunciation: String = "" // 💡 발음 필드 추가! +} + +class ExpressionItem : RealmObject { + @PrimaryKey + var _id: ObjectId = ObjectId() + var topic: String = "" + var lang: String = "EN" + var mainText: String = "" + var subText: String = "" + var pronunciation: String = "" + + // 💡 DB에 단어 리스트를 저장할 수 있도록 RealmList 추가 + var words: RealmList = realmListOf() + + var useCount: Int = 0 + var isMemorized: Boolean = false + var timestamp: Long = System.currentTimeMillis() +} \ No newline at end of file diff --git a/app/src/main/kotlin/bums/lunatic/launcher/settings/childs/Misc.kt b/app/src/main/kotlin/bums/lunatic/launcher/settings/childs/Misc.kt index 556974f3..ed0f0193 100644 --- a/app/src/main/kotlin/bums/lunatic/launcher/settings/childs/Misc.kt +++ b/app/src/main/kotlin/bums/lunatic/launcher/settings/childs/Misc.kt @@ -24,16 +24,27 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.core.widget.doOnTextChanged +import androidx.lifecycle.lifecycleScope import bums.lunatic.launcher.R import bums.lunatic.launcher.databinding.SettingsPrivitServiceBinding import bums.lunatic.launcher.helpers.Constants.Companion.KEY_RSS_URL import bums.lunatic.launcher.helpers.Constants.Companion.KEY_RSS_URL2 import bums.lunatic.launcher.helpers.PrefString import bums.lunatic.launcher.settings.SettingsActivity.Companion.settingsPrefs +import bums.lunatic.launcher.utils.Blog import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.bottomsheet.BottomSheetDialogFragment import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.common.reflect.TypeToken +import com.google.gson.Gson +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody import java.util.Objects +import java.util.concurrent.TimeUnit import kotlin.system.exitProcess @@ -88,9 +99,30 @@ internal class Misc : SettingChild() { PrefString.weatherApiKey.set(text.toString()) settingsChanged = true } + + + binding.inputgemini.setText(PrefString.geminiApiKey.get("")) + binding.inputgemini.doOnTextChanged { text, start, before, count -> + text?.let { + if (text.length > 38 && isValidGeminiApiKey(text.toString())) { + PrefString.geminiApiKey.set(text.toString()) + settingsChanged = true + testGeminiApiCommunication() + } + } + + } + return binding.root } + fun isValidGeminiApiKey(apiKey: String): Boolean { + val trimmedKey = apiKey.trim() + // 구글 API 키 표준 정규식 검사 (AIza로 시작하고 총 39자리) + val regex = Regex("^AIza[a-zA-Z0-9_\\-]{35}\$") + + return regex.matches(trimmedKey) + } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) (requireDialog() as BottomSheetDialog).dismissWithAnimation = true @@ -106,4 +138,158 @@ internal class Misc : SettingChild() { Objects.requireNonNull(binding.inputFeedUrl2.text).toString().trim { it <= ' ' }).apply() } + + data class GeminiResponse(val candidates: List) + data class Candidate(val content: Content) + data class Content(val parts: List) + data class Part(val text: String) + + data class WallContentGroup( + val topic: String, + val translations: List + ) + data class Translation( + val lang: String, + val main: String, + val sub: String, + val pron: String + ) + + private val TEST_PROMPT = """ + 너는 여행 및 외국어 학습 전문가야. 해외 여행지나 일상적인 스몰토크에서 바로 사용할 수 있는 유용한 표현 5가지를 선정해줘. + 각 표현에 대해 영어(EN), 스페인어(ES), 일본어(JA) 세 가지 버전으로 제공해야 해. + + 중요 규칙: + - 모든 외국어 문장의 'pron' 필드에는 한국어 사용자가 읽기 편하도록 최대한 정확한 한국어 발음을 적어줘. + - 일본어(JA)는 한자와 가나를 혼용하고, 'pron'에는 한글 발음을 적어줘. + - 주제(topic)는 '식당에서', '길 묻기', '자기소개', '날씨 이야기' 등 구체적으로 정해줘. + + 반드시 아래 JSON 배열 형식으로만 응답해: + [ + { + "topic": "주제", + "translations": [ + {"lang": "EN", "main": "Sentence", "sub": "한국어 뜻", "pron": "한글 발음"}, + {"lang": "ES", "main": "Frase", "sub": "한국어 뜻", "pron": "한글 발음"}, + {"lang": "JA", "main": "문장", "sub": "한국어 뜻", "pron": "한글 발음"} + ] + } + ] +""".trimIndent() + + // 2. 테스트 함수 + private fun testGeminiApiCommunication() { + val apiKey = PrefString.geminiApiKey.get("").trim() + if (apiKey.isEmpty()) { + Blog.LOGE("테스트 실패: API 키가 입력되지 않았습니다.") + return + } + + // BottomSheetDialogFragment는 viewLifecycleOwner.lifecycleScope를 사용할 수 있습니다. + viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) { + try { + Blog.LOGE("==== Gemini API 통신 테스트 시작 ====") + val client = OkHttpClient.Builder() + .connectTimeout(60, TimeUnit.SECONDS) // 서버 연결 대기 시간 + .readTimeout(60, TimeUnit.SECONDS) // 데이터 전달받는 시간 (중요!) + .writeTimeout(60, TimeUnit.SECONDS) // 데이터 전송 시간 + .build() + val listUrl = "https://generativelanguage.googleapis.com/v1beta/models?key=$apiKey" + val listRequest = Request.Builder().url(listUrl).get().build() + val listResponse = client.newCall(listRequest).execute() + + if (listResponse.isSuccessful) { + val listJson = listResponse.body?.string() ?: "" + val targetModelName = getBestModelName(listJson) + ?: "models/gemini-1.5-flash" // 실패 시 기본값 fallback + + Blog.LOGE("사용 결정된 모델명: $targetModelName") + val finalUrl = "https://generativelanguage.googleapis.com/v1beta/$targetModelName:generateContent?key=$apiKey" + + Blog.LOGE("요청 URL: $finalUrl") + val requestMap = mapOf( + "contents" to listOf( + mapOf("parts" to listOf(mapOf("text" to TEST_PROMPT))) + ), + "generationConfig" to mapOf( + "responseMimeType" to "application/json" + ) + ) + + val jsonRequest = Gson().toJson(requestMap) + val body = jsonRequest.toRequestBody("application/json; charset=utf-8".toMediaTypeOrNull()) + val request = Request.Builder().url(finalUrl).post(body).build() + + val response = client.newCall(request).execute() + + if (response.isSuccessful) { + val rawBody = response.body?.string() ?: "" + Blog.LOGE("1. 통신 성공! Raw Response 길이: ${rawBody.length}") + Blog.LOGE("Raw Data: $rawBody") + + // 파싱 테스트 + val gson = Gson() + val fullResponse = gson.fromJson(rawBody, GeminiResponse::class.java) + val innerJson = fullResponse.candidates[0].content.parts[0].text + + val groupType = object : TypeToken>() {}.type + val parsedList: List = gson.fromJson(innerJson, groupType) + + Blog.LOGE("2. 파싱 성공! 파싱된 세트 개수: ${parsedList.size}") + parsedList.forEachIndexed { index, group -> + Blog.LOGE(" [$index] 주제: ${group.topic} | 대표 문장(EN): ${group.translations.find { it.lang == "EN" }?.main}") + } + Blog.LOGE("==== 테스트 완료 ====") + + } else { + Blog.LOGE("통신 실패: HTTP ${response.code} / ${response.message}") + Blog.LOGE("에러 바디: ${response.body?.string()}") + } + } + +// 2. 최종 URL 조립 (모델명에 이미 'models/'가 포함되어 있으므로 경로 주의) + + } catch (e: Exception) { + Blog.LOGE("통신 중 Exception 발생", e) + } + } + } + + fun getBestModelName(jsonString: String): String? { + return try { + val gson = Gson() + Blog.LOGE("jsonString $jsonString") + val response = gson.fromJson(jsonString, GeminiModelListResponse::class.java) + Blog.LOGE("response $response") + // 1. 조건에 맞는 모델 필터링 + // - 이름에 'gemini-1.5-flash'가 포함됨 + // - 'generateContent' 기능을 지원함 + val matchedModel = response.models.find { model -> + model.name.contains("flash") && + model.supportedGenerationMethods.contains("generateContent") + } + + // 2. 찾은 모델의 name 반환 (없으면 null) + matchedModel?.name + } catch (e: Exception) { + Blog.LOGE("모델 파싱 중 오류 발생", e) + null + } + } + } + +data class GeminiModelListResponse( + val models: List +) + +// 개별 모델의 정보를 담는 클래스 +data class GeminiModelInfo( + val name: String, // 예: "models/gemini-1.5-flash" + val version: String, // 예: "002" + val displayName: String, + val description: String, + val inputTokenLimit: Int, + val outputTokenLimit: Int, + val supportedGenerationMethods: List // 예: ["generateContent", "countTokens"] +) diff --git a/app/src/main/kotlin/bums/lunatic/launcher/wall/MyWallpaperService.kt b/app/src/main/kotlin/bums/lunatic/launcher/wall/MyWallpaperService.kt index 6c7a343c..e7d6681c 100644 --- a/app/src/main/kotlin/bums/lunatic/launcher/wall/MyWallpaperService.kt +++ b/app/src/main/kotlin/bums/lunatic/launcher/wall/MyWallpaperService.kt @@ -1,5 +1,6 @@ package bums.lunatic.launcher.wall +import android.app.ActivityManager import android.app.WallpaperManager import android.content.Intent import android.content.IntentFilter @@ -9,6 +10,7 @@ import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint import android.graphics.Typeface +import android.graphics.Typeface.BOLD import android.os.BatteryManager import android.os.Handler import android.os.HandlerThread @@ -16,8 +18,12 @@ import android.os.ParcelFileDescriptor import android.service.wallpaper.WallpaperService import android.util.Log import android.view.SurfaceHolder +import bums.lunatic.launcher.model.Translation +import bums.lunatic.launcher.model.WallContentGroup import bums.lunatic.launcher.utils.Blog import bums.lunatic.launcher.workers.LocationUpdateService +import com.google.common.reflect.TypeToken +import com.google.gson.Gson import java.io.File import java.text.SimpleDateFormat import java.util.Date @@ -279,73 +285,107 @@ class MyWallpaperService : WallpaperService() { } } + private fun getRamUsage(): String { + val mi = ActivityManager.MemoryInfo() + val activityManager = getSystemService(ACTIVITY_SERVICE) as ActivityManager + activityManager.getMemoryInfo(mi) + val availableMegs = mi.availMem / 1048576L + val totalMegs = mi.totalMem / 1048576L + val usedMegs = totalMegs - availableMegs + val percent = (usedMegs.toDouble() / totalMegs.toDouble() * 100).toInt() + return "RAM: $usedMegs / ${totalMegs}MB ($percent%)" + } + + // 배터리/기기 온도 가져오기 (Celsius) + private fun getDeviceTemperature(): String { + val intent = registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED)) + val temp = intent?.getIntExtra(BatteryManager.EXTRA_TEMPERATURE, 0) ?: 0 + return "Temp: ${temp / 10.0}°C" + } + + private fun getSystemTypeface(lang: String): Typeface { + return when (lang) { + "JA" -> Typeface.create("sans-serif", Typeface.BOLD) // 일본어 한자/가나 대응 + else -> Typeface.create("sans-serif-medium", Typeface.NORMAL) // 영문/스페인어 + } + } + + + private fun drawBitmapFromText(weatherLines: List, isCharging: Boolean = false): Bitmap? { - val dateFull = SimpleDateFormat("yyyy.MM.dd", Locale.KOREAN).format(Date()) -// val timeFull = SimpleDateFormat(if (isCharging) "HH:mm:ss" else "HH:mm", Locale.KOREAN).format(Date()) + val dateFull = SimpleDateFormat("yyyy.MM.dd (E)", Locale.KOREAN).format(Date()) + val ramInfo = getRamUsage() + val tempInfo = getDeviceTemperature() + val batteryInfo = getBatteryStatus().let { "${it.first}% ${if(it.second) "⚡" else ""}" } val finalLines = mutableListOf() - if (weatherLines.isNotEmpty()) finalLines.add(weatherLines[0]) // Index 0: 날씨 -// finalLines.add(timeFull) // Index 2: 시간 + // 1. 날씨 정보 (최상단) + if (weatherLines.isNotEmpty()) finalLines.add(weatherLines[0]) + + // 2. 날짜 (중간 강조) + finalLines.add(dateFull) + + // 3. 시스템 정보 (온도, 메모리, 배터리 통합 한 줄) + finalLines.add("$tempInfo | $ramInfo | $batteryInfo") + + // 4. 주소 정보 (최하단 작게) if (weatherLines.size > 1) { - for (i in 1 until weatherLines.size - 1) finalLines.add(weatherLines[i]) - finalLines.add(weatherLines.last()) // 마지막: 주소 + finalLines.add(weatherLines.last()) } - finalLines.add(dateFull) // Index 1: 날짜 val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.WHITE textAlign = Paint.Align.CENTER - typeface = Typeface.create(Typeface.MONOSPACE, Typeface.NORMAL) - setShadowLayer(10f, 3f, 3f, Color.BLACK) + typeface = Typeface.create(Typeface.MONOSPACE, BOLD) // 가독성을 위해 BOLD + setShadowLayer(12f, 4f, 4f, Color.argb(180, 0, 0, 0)) // 가독성을 위한 그림자 강화 } - val titleSize = 80f - val dateSize = 50f - val addressSize = 30f - val lineSpacingMultiplier = 0.2f // 폰트 크기의 20%를 순수 여백으로 사용 + // 영역별 폰트 사이즈 설정 + val titleSize = 75f // 날씨 + val dateSize = 55f // 날짜 + val systemSize = 35f // 온도/메모리 + val addressSize = 28f // 주소 + val lineSpacingMultiplier = 0.3f - // --- 4. 크기 측정 및 라인별 높이 저장 --- var maxWidth = 0f - var totalHeight = 40f // 상단 여백 - val lineHeights = mutableListOf() // 각 줄이 차지하는 총 높이 - val lineBaselines = mutableListOf() // 각 줄의 글자가 그려질 Baseline 위치 + var totalHeight = 50f + val lineBaselines = mutableListOf() finalLines.forEachIndexed { index, line -> paint.textSize = when (index) { 0 -> titleSize - 2 -> addressSize - else -> dateSize + 1 -> dateSize + 2 -> systemSize + else -> addressSize } val width = paint.measureText(line) if (width > maxWidth) maxWidth = width val metrics = paint.fontMetrics - val fontHeight = metrics.descent - metrics.ascent // 실제 글자 높이 - val leading = paint.textSize * lineSpacingMultiplier // 동적 행간 + val fontHeight = metrics.descent - metrics.ascent + val leading = paint.textSize * lineSpacingMultiplier - val fullLineHeight = fontHeight + leading - - // 현재 줄의 Baseline 계산: 이전까지의 높이 + 글자의 ascent 절대값 lineBaselines.add(totalHeight - metrics.ascent) - totalHeight += fullLineHeight - lineHeights.add(fullLineHeight) + totalHeight += (fontHeight + leading) } - totalHeight += 20f // 하단 여백 + totalHeight += 30f - // --- 5. 비트맵 생성 및 그리기 --- - val bitmap = Bitmap.createBitmap((maxWidth + 100).toInt(), totalHeight.toInt(), Bitmap.Config.ARGB_8888) + val bitmap = Bitmap.createBitmap((maxWidth + 120).toInt(), totalHeight.toInt(), Bitmap.Config.ARGB_8888) val canvas = Canvas(bitmap) val centerX = bitmap.width / 2f finalLines.forEachIndexed { index, line -> paint.textSize = when (index) { 0 -> titleSize - 2 -> addressSize - else -> dateSize + 1 -> dateSize + 2 -> systemSize + else -> addressSize } - // 미리 계산된 Baseline에 그리기만 하면 끝! + // 시스템 정보 줄(index 2)은 약간 불투명하게 처리하여 대비를 줄 수도 있습니다. + paint.alpha = if (index == 2) 200 else 255 + canvas.drawText(line, centerX, lineBaselines[index], paint) } diff --git a/app/src/main/kotlin/bums/lunatic/launcher/workers/TorrentManager.kt b/app/src/main/kotlin/bums/lunatic/launcher/workers/TorrentManager.kt index 78376008..795ba4db 100644 --- a/app/src/main/kotlin/bums/lunatic/launcher/workers/TorrentManager.kt +++ b/app/src/main/kotlin/bums/lunatic/launcher/workers/TorrentManager.kt @@ -165,7 +165,7 @@ class TorrentService : Service() { // 2. 파일 다운로드: 계산된 점수(finalScore)가 낮은 순으로 정렬 if (isCharging) { - val maxSlots = if (isWifiConnected) 5 else 1 + val maxSlots = if (isWifiConnected) 8 else 1 val sortedByPriority = torrentsWithMetadata.sortedBy { it.second } sortedByPriority.forEachIndexed { index, pair -> diff --git a/app/src/main/kotlin/bums/lunatic/launcher/workers/WorkersDb.kt b/app/src/main/kotlin/bums/lunatic/launcher/workers/WorkersDb.kt index 59d7506f..5d479901 100644 --- a/app/src/main/kotlin/bums/lunatic/launcher/workers/WorkersDb.kt +++ b/app/src/main/kotlin/bums/lunatic/launcher/workers/WorkersDb.kt @@ -34,6 +34,9 @@ import bums.lunatic.launcher.home.tokiz.ContentsPageInfo import bums.lunatic.launcher.home.tokiz.HistoryItem import bums.lunatic.launcher.home.tokiz.LastInfo import bums.lunatic.launcher.home.tokiz.ReaderConfig +import bums.lunatic.launcher.model.ExpressionItem +import bums.lunatic.launcher.model.ExpressionWord +import bums.lunatic.launcher.model.WallContentGroup import bums.lunatic.launcher.utils.Blog import bums.lunatic.launcher.utils.JamoUtils import bums.lunatic.launcher.utils.afterDay @@ -76,10 +79,42 @@ object WorkersDb { LastInfo::class, HistoryItem::class, ReaderConfig::class, ContentsCollection::class, ContentsPageInfo::class, AppUsageLog::class, WidgetData::class, + ExpressionItem::class,ExpressionWord::class ) //,UserActionModel::class + fun insertExpressions(groups: List) { + getRealm().writeBlocking { + groups.forEach { group -> + group.translations.forEach { trans -> + + // 1. 단어들을 RealmObject 리스트로 변환 + val realmWords = trans.words.map { parsedWord -> + ExpressionWord().apply { + word = parsedWord.word + meaning = parsedWord.meaning + pronunciation = parsedWord.pron + } + } + + // 2. ExpressionItem 생성 및 저장 + copyToRealm(ExpressionItem().apply { + topic = group.topic + lang = trans.lang + mainText = trans.main + subText = trans.sub + pronunciation = trans.pron + + // 💡 변환한 단어 리스트 넣기 + words.addAll(realmWords) + + timestamp = System.currentTimeMillis() + }) + } + } + } + } // [추가] 앱/연락처 사용 시 로그 저장 (기존 updateAppUse 대신 이거 호출) fun logAppUsage(key: String, type: UsageLogType = UsageLogType.APP, datetime: UsageUpdateType) { diff --git a/app/src/main/res/layout/fragment_learning.xml b/app/src/main/res/layout/fragment_learning.xml new file mode 100644 index 00000000..d20ed860 --- /dev/null +++ b/app/src/main/res/layout/fragment_learning.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/rss_activity.xml b/app/src/main/res/layout/rss_activity.xml index 9415d99a..45aacd1e 100644 --- a/app/src/main/res/layout/rss_activity.xml +++ b/app/src/main/res/layout/rss_activity.xml @@ -126,6 +126,12 @@ + + + + + + + + +