This commit is contained in:
lunaticbum 2026-04-01 17:59:30 +09:00
parent 9526104a4a
commit e9e585bad0
13 changed files with 857 additions and 34 deletions

View File

@ -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

View File

@ -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<Candidate>)
data class Candidate(val content: Content)
data class Content(val parts: List<Part>)
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<WallContentGroup>? {
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<List<WallContentGroup>>() {}.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<WallContentsWorker>(
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() {
}
}
}
}
data class GeminiResponse(val candidates: List<Candidate>)
data class Candidate(val content: Content)
data class Content(val parts: List<Part>)
data class Part(val text: String)

View File

@ -10,10 +10,12 @@ enum class PrefString : PrefKey<String> {
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 ?: "") ?: ""

View File

@ -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<ExpressionItem>().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<ExpressionItem>("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<ExpressionAdapter.ViewHolder>() {
private var items: List<ExpressionItem> = emptyList()
fun submitList(newItems: List<ExpressionItem>) {
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)
}
}

View File

@ -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

View File

@ -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<WordItem> = emptyList() // 💡 단어장 배열 추가!
)
data class WallContentGroup(
val topic: String,
val translations: List<Translation>
)
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<ExpressionWord> = realmListOf()
var useCount: Int = 0
var isMemorized: Boolean = false
var timestamp: Long = System.currentTimeMillis()
}

View File

@ -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<Candidate>)
data class Candidate(val content: Content)
data class Content(val parts: List<Part>)
data class Part(val text: String)
data class WallContentGroup(
val topic: String,
val translations: List<Translation>
)
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<List<WallContentGroup>>() {}.type
val parsedList: List<WallContentGroup> = 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<GeminiModelInfo>
)
// 개별 모델의 정보를 담는 클래스
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<String> // 예: ["generateContent", "countTokens"]
)

View File

@ -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<String>, 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<String>()
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<Float>() // 각 줄이 차지하는 총 높이
val lineBaselines = mutableListOf<Float>() // 각 줄의 글자가 그려질 Baseline 위치
var totalHeight = 50f
val lineBaselines = mutableListOf<Float>()
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)
}

View File

@ -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 ->

View File

@ -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<WallContentGroup>) {
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) {

View File

@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="#DD121212"
android:padding="16dp">
<HorizontalScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:scrollbars="none">
<LinearLayout
android:id="@+id/layout_languages"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingBottom="8dp" />
</HorizontalScrollView>
<HorizontalScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:scrollbars="none">
<LinearLayout
android:id="@+id/layout_topics"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingBottom="16dp" />
</HorizontalScrollView>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_expressions"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:paddingBottom="80dp" /> </LinearLayout>

View File

@ -126,6 +126,12 @@
<!-- style="@style/CommonFabStyle"-->
<!-- android:id="@+id/btn_img4"-->
<!-- />-->
<bums.lunatic.launcher.view.FloatingActionButton
app:fab_label="🌍"
style="@style/CommonFabStyle"
android:id="@+id/btn_learn" />
<bums.lunatic.launcher.view.FloatingActionButton
style="@style/CommonFabStyle"
app:fab_label="❌"

View File

@ -134,6 +134,27 @@
android:singleLine="true" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/geminiInputLayout"
android:layout_width="@dimen/threeTwentyFour"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/twelve"
android:hint="@string/owm_key"
app:endIconMode="clear_text"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/owmInputLayout">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/inputgemini"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:imeOptions="actionDone"
android:singleLine="true" />
</com.google.android.material.textfield.TextInputLayout>
<!-- <com.google.android.material.textview.MaterialTextView-->
<!-- android:id="@+id/doubleTapLock"-->
<!-- android:layout_width="wrap_content"-->