This commit is contained in:
lunaticbum 2026-02-03 18:07:00 +09:00
parent cfe4686cef
commit 12c135dc95
13 changed files with 931 additions and 87 deletions

View File

@ -59,6 +59,8 @@ import bums.lunatic.launcher.receiver.NLService
import bums.lunatic.launcher.receiver.SmsReceiver
import bums.lunatic.launcher.settings.SettingsActivity
import bums.lunatic.launcher.utils.Blog
import bums.lunatic.launcher.workers.UsageLogType
import bums.lunatic.launcher.workers.UsageUpdateType
import bums.lunatic.launcher.workers.WorkersDb
import com.google.android.material.color.DynamicColors
import io.realm.kotlin.UpdatePolicy
@ -182,6 +184,7 @@ open class LauncherActivity : CommonActivity() {
val intent = packageManager.getLaunchIntentForPackage(packageName)
if (intent != null) {
WorkersDb.logAppUsage(packageName, UsageLogType.APP,datetime = UsageUpdateType.DATETIME)
// 앱이 설치되어 있으면 실행
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(intent)

View File

@ -10,6 +10,8 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import android.widget.AdapterView
import android.widget.ArrayAdapter
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.widget.doOnTextChanged
@ -17,13 +19,17 @@ import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager
import bums.lunatic.launcher.BuildConfig
import bums.lunatic.launcher.R
import bums.lunatic.launcher.apps.AppMenu
import bums.lunatic.launcher.databinding.BottomSheetAppDrawerBinding // XML 이름에 맞춰 바인딩 클래스 생성됨
import bums.lunatic.launcher.helpers.PrefBoolean
import bums.lunatic.launcher.helpers.PrefLong
import bums.lunatic.launcher.model.AppInfo
import bums.lunatic.launcher.model.SimpleContact
import bums.lunatic.launcher.utils.Blog
import bums.lunatic.launcher.utils.CategoryGrouper
import bums.lunatic.launcher.utils.EnToKo
import bums.lunatic.launcher.utils.JamoUtils
import bums.lunatic.launcher.utils.SimpleTransliterater
import bums.lunatic.launcher.workers.UsageLogType
import bums.lunatic.launcher.workers.UsageUpdateType
import bums.lunatic.launcher.workers.WorkersDb
@ -38,6 +44,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.net.URLEncoder
import kotlin.math.min
class AppDrawerBottomSheet : BottomSheetDialogFragment() {
@ -89,6 +96,20 @@ class AppDrawerBottomSheet : BottomSheetDialogFragment() {
return binding.root
}
private val categoryMap = mapOf(
"전체" to "ALL",
"게임" to "GAME",
"생산성" to "PRODUCTIVITY",
"소셜" to "SOCIAL",
"오디오" to "AUDIO",
"비디오" to "VIDEO",
"이미지" to "IMAGE",
"지도" to "MAPS",
"뉴스" to "NEWS",
"기타" to "UNDEFINED" // 혹은 UNKNOWN
)
private var currentScope: String = CategoryGrouper.SCOPE_ALL
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
@ -97,9 +118,38 @@ class AppDrawerBottomSheet : BottomSheetDialogFragment() {
setupListeners()
setupKeyboardObserver()
// 초기 데이터 로드
fetchApps()
setupCategorySpinner()
}
private var currentCategoryKey: String = "ALL"
private fun setupCategorySpinner() {
// 1. 스피너에 들어갈 데이터(표시 이름들) 준비
val displayList = categoryMap.keys.toList()
// 2. 어댑터 설정 (기본 안드로이드 레이아웃 사용)
val adapter = ArrayAdapter(
requireContext(),
android.R.layout.simple_spinner_item,
displayList
)
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
binding.categorySpinner.adapter = adapter
// 3. 선택 리스너 설정
binding.categorySpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
val selectedDisplayName = displayList[position]
currentCategoryKey = categoryMap[selectedDisplayName] ?: "ALL"
// 선택 변경 시 목록 갱신 (검색어 유지)
fetchApps(binding.searchInput.text.toString())
}
override fun onNothingSelected(parent: AdapterView<*>?) {}
}
}
private fun setupKeyboardObserver() {
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { _, insets ->
// 키보드(IME)가 보이는지 확인
@ -261,10 +311,97 @@ class AppDrawerBottomSheet : BottomSheetDialogFragment() {
}
private fun checkResult(keyword: String) {
// 기존 Activity의 checkResult 로직 구현 (필요시 부모 액티비티 함수 호출)
// (lActivity as? LauncherActivity)?.openSearchMenus(keyword) ...
// 1. 현재 필터링된 리스트에서 첫 번째 아이템들을 가져옴
val filteredApp = appsAdapter?.oldList?.firstOrNull()
val filteredContact = contactAdapter?.oldList?.firstOrNull()
val appCount = appsAdapter?.oldList?.size ?: 0
val contactCount = contactAdapter?.oldList?.size ?: 0
// Case 1: 앱만 하나 검색된 경우
if (appCount == 1 && contactCount != 1) {
launchApp(filteredApp)
}
// Case 2: 연락처만 하나 검색된 경우
else if (contactCount == 1 && appCount != 1) {
launchContact(filteredContact)
}
// Case 3: 앱과 연락처가 동시에 하나씩 검색된 경우 (핵심 로직)
else if (appCount == 1 && contactCount == 1) {
val appUsage = filteredApp?.clickCount ?: 0
val contactUsage = filteredContact?.touchCount ?: 0
// 더 많이 사용한 쪽을 실행 (사용 횟수가 같으면 앱을 우선순위로 두거나 함)
if (contactUsage > appUsage) {
launchContact(filteredContact)
} else {
launchApp(filteredApp)
}
}
// Case 4: 그 외 (결과가 없거나 여러 개인 경우) 구글 검색 실행
else {
// openSearchApps("https://www.google.com/search?q=${keyword}", "com.android.chrome")
askGemini(keyword)
// dismiss()
}
}
private fun containsKorean(text: String): Boolean {
return text.any { it.code in 0xAC00..0xD7AF }
}
private fun askGemini(keyword: String) {
try {
val isKorean = keyword.any { it.code in 0xAC00..0xD7AF }
// 반대 언어 키워드 생성
val converted = if (isKorean) {
SimpleTransliterater.koToEn(keyword) // 한글 -> 영타
} else {
SimpleTransliterater.enToKo(keyword) // 영어 -> 한타(자모나열)
}
Blog.LOGE("keyword >>>> ${keyword} converted >>> ${converted}")
// 제미나이는 'ㅇㅏㄴㄴㅕㅇ' 같은 자모 나열도 맥락상 '안녕'으로 매우 잘 이해합니다.
val combinedQuery = if (converted != keyword) {
"$keyword ($converted) 에 대해 알려줘"
} else {
keyword
}
val encodedQuery = URLEncoder.encode(combinedQuery, "UTF-8")
val uri = Uri.parse("https://gemini.google.com/app?q=$encodedQuery")
val intent = Intent(Intent.ACTION_VIEW, uri).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
startActivity(intent)
} catch (e: Exception) {
// 실패 시 일반 구글 검색으로 폴백
openSearchApps("https://www.google.com/search?q=${URLEncoder.encode(keyword, "UTF-8")}")
}
}
// 코드 가독성을 위한 헬퍼 함수: 앱 실행
private fun launchApp(app: AppInfo?) {
app?.pkgName?.let { pkg ->
// 사용 기록 로깅
WorkersDb.logAppUsage(pkg, UsageLogType.APP, UsageUpdateType.DATETIME)
// 앱 실행
val intent = requireContext().packageManager.getLaunchIntentForPackage(pkg)
intent?.let { startActivity(it) }
dismiss()
}
}
// 코드 가독성을 위한 헬퍼 함수: 연락처 다이얼러 실행
private fun launchContact(contact: SimpleContact?) {
contact?.let { c ->
// 사용 기록 로깅
c.id?.let { id -> WorkersDb.logAppUsage(id, UsageLogType.CONTACT, UsageUpdateType.DATETIME) }
// 전화 걸기 화면 실행
val intent = Intent(Intent.ACTION_DIAL, Uri.parse("tel:${c.phoneNumber}"))
startActivity(intent)
dismiss()
}
}
private fun filterAppsList(searchString: String) {
fetchApps(searchString)
@ -305,104 +442,152 @@ class AppDrawerBottomSheet : BottomSheetDialogFragment() {
}
try {
// 2. [쿼리 구성]
var appQuery = realm.query<AppInfo>()
// =========================================================
// 2. [앱 검색 로직 변경] DB 쿼리 -> 메모리 필터링
// =========================================================
if (!keyword.isNullOrEmpty()) {
val firstChar = keyword.first().toString()
if (JamoUtils.CHOSUNG.contains(firstChar)) {
appQuery = appQuery.query("appNameChosung CONTAINS[c] $0 OR alphaCho CONTAINS[c] $0", keyword)
} else if (java.util.regex.Pattern.matches("^[가-힣]*\$", keyword)) {
appQuery = appQuery.query("appName CONTAINS[c] $0 OR koreanName CONTAINS[c] $0", keyword)
} else {
appQuery = appQuery.query("appName CONTAINS[c] $0 OR pkgName CONTAINS[c] $0 OR category CONTAINS[c] $0", keyword)
}
}
// 3. [DB 조회]
val results = appQuery
// 2-1. 일단 모든 앱을 가져옵니다. (기본 정렬: 클릭수, 최근사용순)
// 쿼리 조건 없이 모두 가져옴
val baseAppQuery = realm.query<AppInfo>()
.sort("clickCount", Sort.DESCENDING)
.sort("lastUseDate", Sort.DESCENDING)
.find()
// [중요] DB가 비어있다면(앱 설치 직후 등), 여기서 앱 스캔을 요청하거나 빈 상태 처리
if (results.isEmpty() && keyword.isNullOrEmpty()) {
// 필요 시 AppInfoGetter 워커를 즉시 실행하는 로직 추가 가능
}
// 4. [데이터 가공]
val allApps = results.map { realm.copyFromRealm(it) }
val scopedAppsList = baseAppQuery.map { realm.copyFromRealm(it) }
.filter { appInfo ->
try {
val isLaunchable = try {
pm.getLaunchIntentForPackage(appInfo.pkgName ?: "") != null
} catch (e: Exception) {
false
}
}
} catch (e: Exception) { false }
val isVisible = binding.hidden.isSelected || (appInfo.visibilityMode == 0)
val mainAppList = allApps.filter {
if (binding.hidden.isSelected) {
// [핵심 변경] 단순 문자열 비교 필터링
val isCategoryMatch = if (currentCategoryKey == "ALL") {
true
} else {
it.visibilityMode == 0
// AppInfoGetter에서 저장한 값("GAME", "AUDIO" 등)과 비교
appInfo.category == currentCategoryKey
}
isLaunchable && isVisible && isCategoryMatch
}
// 2-2. Realm 객체를 메모리 객체로 변환 (속도 향상 및 스레드 안전)
// 여기서 visible 필터링 등을 미리 해도 좋습니다.
// 2-3. 키워드에 따라 필터링 및 정렬 (유사도 적용)
val filteredApps = if (keyword.isNullOrEmpty()) {
// 키워드가 없으면 기본 정렬 그대로 사용
scopedAppsList
} else {
// 검색어 전처리 (한/영 변환 등)
val isKorean = keyword.any { it.code in 0xAC00..0xD7AF }
val converted = if (isKorean) SimpleTransliterater.koToEn(keyword) else SimpleTransliterater.enToKo(keyword)
// 검색어 후보군 (예: "카카오", "zkzk")
val searchTerms = listOfNotNull(keyword, converted).distinct()
// [핵심] 각 앱에 대해 점수 계산
scopedAppsList.mapNotNull { app ->
// 1. 검색 대상 필드들을 리스트로 묶습니다. (null인 필드는 자동으로 제외됨)
val targetFields = listOfNotNull(
app.appName, // 앱 이름
app.pkgName, // 패키지명 (예: com.kakao.talk)
app.category // 카테고리 (예: Social, Game)
)
// 2. 여러 검색어(term) 중 가장 높은 점수를 계산
val maxScore = searchTerms.maxOf { term ->
// [1단계] 서치 인덱스 확인 (가장 빠름)
// 만약 makeSearchIndex에 패키지명 등을 이미 포함시켰다면 여기서 바로 잡힙니다.
if (app.searchIndex.contains(term, true)) return@maxOf 1.0
// [2단계] 각 필드(이름, 패키지, 카테고리)에 대해 점수 계산 후 가장 높은 것 선택
// targetFields 리스트를 순회하며 가장 높은 매칭 점수를 찾습니다.
val fieldMaxScore = targetFields.maxOf { field ->
// A. 단순 포함 or 초성 매칭 (100점)
if (SimpleTransliterater.match(field, term)) {
1.0
} else {
// B. 유사도 계산 (0.0 ~ 1.0점)
SimpleTransliterater.getSimilarity(field, term)
}
}
var contactQuery = realm.query<SimpleContact>()
fieldMaxScore // 해당 검색어(term)에 대한 최종 점수 반환
}
// 3. 유사도 임계값 체크 (0.3점 이상만 통과)
if (maxScore > 0.3) {
Pair(app, maxScore)
} else {
null
}
}
.sortedByDescending { it.second } // 점수 높은 순 정렬
.map { it.first } // 결과 반환// 앱 정보만 추출
}
// =========================================================
// 3. [연락처 검색 로직 변경] 동일한 방식 적용
// =========================================================
var filteredContacts: List<SimpleContact> = emptyList()
val allContacts = realm.query<SimpleContact>()
.sort("touchCount", Sort.DESCENDING)
.find()
.map { realm.copyFromRealm(it) } // 메모리로 복사
if (!keyword.isNullOrEmpty()) {
// 이름, 초성, 전화번호로 검색
val firstChar = keyword.first().toString()
if (JamoUtils.CHOSUNG.contains(firstChar)) {
contactQuery = contactQuery.query("chosung CONTAINS[c] $0", keyword)
} else if (java.util.regex.Pattern.matches("^[가-힣]*\$", keyword)) {
contactQuery = contactQuery.query("name CONTAINS[c] $0", keyword)
// 연락처도 전체 로드 (보통 수천 개 단위까지는 문제 없음)
val searchTerms = listOfNotNull(keyword, if (keyword.any { it.code in 0xAC00..0xD7AF }) SimpleTransliterater.koToEn(keyword) else SimpleTransliterater.enToKo(keyword)).distinct()
filteredContacts = allContacts.mapNotNull { contact ->
val maxScore = searchTerms.maxOf { term ->
// 연락처 이름이나 번호 매칭 확인
if (SimpleTransliterater.match(contact.searchIndex ?: "", term)) return@maxOf 1.0
if (SimpleTransliterater.match(contact.name ?: "", term)) return@maxOf 1.0
if (contact.phoneNumber?.contains(term) == true) return@maxOf 1.0
// 유사도 확인
SimpleTransliterater.getSimilarity(contact.name ?: "", term)
}
if (maxScore > 0.4) Pair(contact, maxScore) else null
}
.sortedByDescending { it.second }
.take(10) // 상위 10개만
.map { it.first }
} else {
contactQuery = contactQuery.query("name CONTAINS[c] $0 OR phoneNumber CONTAINS $0", keyword)
}
} else {
// 검색어 없을 때: 자주 쓰는 연락처(터치 횟수 순) 또는 최근 연락처 상위 10개만 노출 (너무 많으면 스크롤 힘듦)
}
contactQuery = contactQuery.sort("touchCount", Sort.DESCENDING).limit(10)
val contactsResult = contactQuery.find()
val contactsList = contactsResult.map { realm.copyFromRealm(it) }.filter {
if (binding.hidden.isSelected) {
true
} else {
it.visibilityMode == 0
}
filteredContacts = allContacts.take(10)
}
// 6. [UI 업데이트]
// 4. [UI 업데이트] (기존 로직 유지)
withContext(Dispatchers.Main) {
if (unifiedList.isNotEmpty()) {
binding.recAppsList.visibility = View.VISIBLE
recAdapter?.submitList(unifiedList)
}
// 2. 전체 앱 리스트 업데이트
// 앱 리스트 갱신
packageList.clear()
packageList.addAll(mainAppList)
packageList.addAll(filteredApps)
appsAdapter?.updateData(packageList)
binding.appsCount.text = "${packageList.size} Apps"
if (contactsList.isNotEmpty()) {
// 연락처 리스트 갱신
if (filteredContacts.isNotEmpty()) {
binding.contactList.visibility = View.VISIBLE
contactAdapter?.updateData(contactsList)
contactAdapter?.updateData(filteredContacts)
} else {
binding.contactList.visibility = View.GONE
contactAdapter?.updateData(emptyList()) // 빈 리스트로 갱신하여 잔상 제거
contactAdapter?.updateData(emptyList())
}
}
} catch (e: Exception) {
e.printStackTrace() // 여기서 에러가 나면 앱 목록이 안 뜹니다. 로그캣(Logcat)을 확인해보세요.
e.printStackTrace()
}

View File

@ -130,7 +130,7 @@ internal class AppMenu : BottomSheetDialogFragment() {
binding.lastTouchDate.text =
"최종 실행 일시 : ".plus(SimpleDateFormat("yyyy-MM-dd HH:mm").format(Date(app.lastUseDate)))
app.currentInstalled = true
binding.alterName.setText(app.koreanName)
binding.alterName.setText(app.searchIndex)
binding.recommend.isChecked = app.blockRecommend
binding.listVisible.isChecked = app.visibilityMode == 1

View File

@ -77,7 +77,7 @@ internal class AppsAdapter(
private val fragmentManager: FragmentManager,
private val appsCount: TextView?) : RecyclerView.Adapter<AppsViewHolder>() {
private var oldList = mutableListOf<AppInfo>()
var oldList = mutableListOf<AppInfo>()
// private var appGravity: Int = Gravity.CENTER
companion object {

View File

@ -67,7 +67,7 @@ internal class ContactAdapter (
private val context: Context,
private val fragmentManager: FragmentManager) : RecyclerView.Adapter<ContactViewHolder>() {
private var oldList = mutableListOf<SimpleContact>()
var oldList = mutableListOf<SimpleContact>()
private var appGravity: Int = Gravity.CENTER
companion object {

View File

@ -23,4 +23,7 @@ class AppInfo : RealmObject {
// [신규] true면 추천 리스트에 절대 안 뜸
var blockRecommend: Boolean = false
var searchIndex: String = ""
}

View File

@ -1,6 +1,7 @@
package bums.lunatic.launcher.model
import bums.lunatic.launcher.utils.JamoUtils
import bums.lunatic.launcher.utils.SimpleTransliterater
import io.realm.kotlin.types.RealmObject
import io.realm.kotlin.types.annotations.PrimaryKey
@ -18,11 +19,14 @@ class SimpleContact : RealmObject {
// [신규] true면 추천 리스트에 절대 안 뜸
var blockRecommend: Boolean = false
var searchIndex: String = ""
constructor(id: String, name: String, phoneNumber: String) {
this.id = id
this.name = name
this.phoneNumber = phoneNumber
chosung = JamoUtils.split(name).joinToString("")
this.searchIndex = SimpleTransliterater.makeSearchIndex(name)
}
constructor()

View File

@ -0,0 +1,171 @@
package bums.lunatic.launcher.utils
// PlayCategory.kt 내부에 추가하거나 하단에 작성
object CategoryGrouper {
// 필터로 사용할 스코프 상수
const val SCOPE_ALL = "ALL"
const val SCOPE_GAME = "GAME"
const val SCOPE_SOCIAL = "SOCIAL"
const val SCOPE_MEDIA = "MEDIA"
const val SCOPE_PRODUCTIVITY = "PROD"
// 게임 카테고리 목록 (PlayCategory 상수를 참조)
private val GAME_CATEGORIES = setOf(
// [수정] AppInfoGetter에서 저장하는 "GAME" 추가
"GAME",
// 기존 상세 카테고리들 (Play Store 크롤링 등을 통해 상세 정보를 가져오는 경우를 대비해 유지)
PlayCategory.GAME_ACTION, PlayCategory.GAME_ADVENTURE, PlayCategory.GAME_ARCADE,
PlayCategory.GAME_BOARD, PlayCategory.GAME_CARD, PlayCategory.GAME_CASINO,
PlayCategory.GAME_CASUAL, PlayCategory.GAME_EDUCATIONAL, PlayCategory.GAME_MUSIC,
PlayCategory.GAME_PUZZLE, PlayCategory.GAME_RACING, PlayCategory.GAME_ROLE_PLAYING,
PlayCategory.GAME_SIMULATION, PlayCategory.GAME_SPORTS, PlayCategory.GAME_STRATEGY,
PlayCategory.GAME_TRIVIA, PlayCategory.GAME_WORD
)
private val SOCIAL_CATEGORIES = setOf(
// [수정] AppInfoGetter에서 저장하는 "SOCIAL" 추가
"SOCIAL",
PlayCategory.SOCIAL, PlayCategory.COMMUNICATION, PlayCategory.DATING
)
private val MEDIA_CATEGORIES = setOf(
// [수정] AppInfoGetter에서 저장하는 값들 추가 ("AUDIO", "VIDEO", "IMAGE")
"AUDIO", "VIDEO", "IMAGE",
PlayCategory.MUSIC_AND_AUDIO, PlayCategory.VIDEO_PLAYERS,
PlayCategory.ENTERTAINMENT, PlayCategory.PHOTOGRAPHY
)
// 앱의 카테고리가 현재 선택된 스코프에 속하는지 확인하는 함수
fun isInScope(appCategory: String?, scope: String): Boolean {
if (scope == SCOPE_ALL) return true
if (appCategory == null) return false // 카테고리 정보 없으면 기타 처리
return when (scope) {
SCOPE_GAME -> GAME_CATEGORIES.contains(appCategory)
SCOPE_SOCIAL -> SOCIAL_CATEGORIES.contains(appCategory)
SCOPE_MEDIA -> MEDIA_CATEGORIES.contains(appCategory)
SCOPE_PRODUCTIVITY -> !GAME_CATEGORIES.contains(appCategory) &&
!SOCIAL_CATEGORIES.contains(appCategory) &&
!MEDIA_CATEGORIES.contains(appCategory) // 나머지를 생산성/기타로 취급 예시
else -> true
}
}
}
object PlayCategory {
// 앱(App) 카테고리
const val ART_AND_DESIGN = "Art & Design"
const val AUTO_AND_VEHICLES = "Auto & Vehicles"
const val BEAUTY = "Beauty"
const val BOOKS_AND_REFERENCE = "Books & Reference"
const val BUSINESS = "Business"
const val COMICS = "Comics"
const val COMMUNICATION = "Communication"
const val DATING = "Dating"
const val DAYDREAM = "Daydream" // 일부 기기용
const val EDUCATION = "Education"
const val ENTERTAINMENT = "Entertainment"
const val EVENTS = "Events"
const val FINANCE = "Finance"
const val FOOD_AND_DRINK = "Food & Drink"
const val HEALTH_AND_FITNESS = "Health & Fitness"
const val HOUSE_AND_HOME = "House & Home"
const val LIBRARIES_AND_DEMO = "Libraries & Demo"
const val LIFESTYLE = "Lifestyle"
const val MAPS_AND_NAVIGATION = "Maps & Navigation"
const val MEDICAL = "Medical"
const val MUSIC_AND_AUDIO = "Music & Audio"
const val NEWS_AND_MAGAZINES = "News & Magazines"
const val PARENTING = "Parenting"
const val PERSONALIZATION = "Personalization"
const val PHOTOGRAPHY = "Photography"
const val PRODUCTIVITY = "Productivity"
const val SHOPPING = "Shopping"
const val SOCIAL = "Social"
const val SPORTS = "Sports"
const val TOOLS = "Tools"
const val TRAVEL_AND_LOCAL = "Travel & Local"
const val VIDEO_PLAYERS = "Video Players & Editors"
const val WEATHER = "Weather"
// 게임(Game) 카테고리
const val GAME_ACTION = "Action"
const val GAME_ADVENTURE = "Adventure"
const val GAME_ARCADE = "Arcade"
const val GAME_BOARD = "Board"
const val GAME_CARD = "Card"
const val GAME_CASINO = "Casino"
const val GAME_CASUAL = "Casual"
const val GAME_EDUCATIONAL = "Educational"
const val GAME_MUSIC = "Music"
const val GAME_PUZZLE = "Puzzle"
const val GAME_RACING = "Racing"
const val GAME_ROLE_PLAYING = "Role Playing"
const val GAME_SIMULATION = "Simulation"
const val GAME_SPORTS = "Sports"
const val GAME_STRATEGY = "Strategy"
const val GAME_TRIVIA = "Trivia"
const val GAME_WORD = "Word"
// 전체 카테고리 리스트 (앱 + 게임)
val ALL_CATEGORIES = listOf(
ART_AND_DESIGN,
AUTO_AND_VEHICLES,
BEAUTY,
BOOKS_AND_REFERENCE,
BUSINESS,
COMICS,
COMMUNICATION,
DATING,
DAYDREAM,
EDUCATION,
ENTERTAINMENT,
EVENTS,
FINANCE,
FOOD_AND_DRINK,
HEALTH_AND_FITNESS,
HOUSE_AND_HOME,
LIBRARIES_AND_DEMO,
LIFESTYLE,
MAPS_AND_NAVIGATION,
MEDICAL,
MUSIC_AND_AUDIO,
NEWS_AND_MAGAZINES,
PARENTING,
PERSONALIZATION,
PHOTOGRAPHY,
PRODUCTIVITY,
SHOPPING,
SOCIAL,
SPORTS,
TOOLS,
TRAVEL_AND_LOCAL,
VIDEO_PLAYERS,
WEATHER,
GAME_ACTION,
GAME_ADVENTURE,
GAME_ARCADE,
GAME_BOARD,
GAME_CARD,
GAME_CASINO,
GAME_CASUAL,
GAME_EDUCATIONAL,
GAME_MUSIC,
GAME_PUZZLE,
GAME_RACING,
GAME_ROLE_PLAYING,
GAME_SIMULATION,
GAME_SPORTS,
GAME_STRATEGY,
GAME_TRIVIA,
GAME_WORD,
)
}

View File

@ -0,0 +1,461 @@
package bums.lunatic.launcher.utils
import java.util.Locale
object SimpleTransliterater {
// 기존 키보드 매핑 상수 유지
private val EN_KEY = "qwertyuiopasdfghjklzxcvbnmQWERTYUIOPASDFGHJKLZXCVBNM"
private val KO_KEY = "ㅂㅈㄷㄱㅅㅛㅕㅑㅐㅔㅁㄴㅇㄹㅎㅗㅓㅏㅣㅋㅌㅊㅍㅠㅜㅡㅃㅉㄸㄲㅆㅛㅕㅑㅒㅖㅁㄴㅇㄹㅎㅗㅓㅏㅣㅋㅌㅊㅍㅠㅜㅡ"
// 자모 상수
private val CHOSUNG = listOf("", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "")
// 로마자 변환을 위한 초성 매핑 (발음 기반 검색용)
private val CHOSUNG_EN = listOf("g", "kk", "n", "d", "tt", "l", "m", "b", "pp", "s", "ss", "", "j", "jj", "ch", "k", "t", "p", "h")
private val JUNGSUNG = listOf("", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "")
private val JUNGSUNG_EN = listOf("a", "ae", "ya", "yae", "eo", "e", "yeo", "ye", "o", "wa", "wae", "oe", "yo", "u", "wo", "we", "wi", "yu", "eu", "ui", "i")
private val JONGSUNG = listOf("", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "")
private val JONGSUNG_EN = listOf("", "k", "kk", "ks", "n", "nj", "nh", "t", "l", "lk", "lm", "lb", "ls", "lt", "lp", "lh", "m", "p", "ps", "t", "ss", "ng", "t", "t", "k", "t", "p", "h")
private fun mapKoToEn(ko: String): String {
// 복합 자모 처리 (예: ㄶ -> ne)
val map = mapOf("" to "rt", "" to "sw", "" to "sg", "" to "fr", "" to "fa", "" to "fq", "" to "ft", "" to "fx", "" to "fv", "" to "fg", "" to "qt", "" to "hk", "" to "hl", "" to "ho", "" to "nj", "" to "nl", "" to "np", "" to "ml")
if (map.containsKey(ko)) return map[ko]!!
val idx = KO_KEY.indexOf(ko)
return if (idx != -1) EN_KEY[idx % 52].toString() else ko
}
fun makeSearchIndex(text: String): String {
val sb = StringBuilder()
val lowerText = text.lowercase(Locale.getDefault()).trim()
sb.append(lowerText).append("|") // 1. 원본
sb.append(makeChosung(text)).append("|") // 2. 초성
if (isKorean(text)) {
sb.append(hangulToRoman(text)).append("|") // 3. 한글 -> 영어 (카카오 -> kakao)
} else if (isEnglish(text)) {
// [NEW] 4. 영어 -> 한글 발음 (Instagram -> 인스타그램, 인스타)
val hangulPronunciation = englishToHangul(text)
sb.append(hangulPronunciation).append("|")
sb.append(makeChosung(hangulPronunciation)).append("|") // 발음의 초성도 추가 (인스타그램 -> ㅇㅅㅌㄱㄹ)
}
return sb.toString()
}
/**
* [유사도 계산] (Levenshtein Distance 알고리즘)
* 문자열이 얼마나 유사한지 0.0 ~ 1.0 사이의 점수로 반환합니다.
* 1.0이면 완전 일치, 0.0이면 완전 불일치.
*/
fun getSimilarity(s1: String, s2: String): Double {
val longer = if (s1.length > s2.length) s1 else s2
val shorter = if (s1.length > s2.length) s2 else s1
if (longer.isEmpty()) return 1.0 // 둘 다 공백이면 일치
val distance = levenshteinDistance(longer, shorter)
return (longer.length - distance).toDouble() / longer.length.toDouble()
}
// 편집 거리(Levenshtein Distance) 계산 함수
private fun levenshteinDistance(lhs: CharSequence, rhs: CharSequence): Int {
val m = lhs.length
val n = rhs.length
val dp = Array(m + 1) { IntArray(n + 1) }
for (i in 0..m) dp[i][0] = i
for (j in 0..n) dp[0][j] = j
for (i in 1..m) {
for (j in 1..n) {
val cost = if (lhs[i - 1] == rhs[j - 1]) 0 else 1
dp[i][j] = kotlin.math.min(
dp[i - 1][j] + 1,
kotlin.math.min(dp[i][j - 1] + 1, dp[i - 1][j - 1] + cost)
)
}
}
return dp[m][n]
}
fun koToEn(input: String): String {
val sb = StringBuilder()
for (char in input) {
val code = char.code
if (code in 0xAC00..0xD7AF) { // 완성형 한글
val base = code - 0xAC00
val cho = base / 21 / 28
val jung = (base / 28) % 21
val jong = base % 28
sb.append(mapKoToEn(CHOSUNG[cho]))
sb.append(mapKoToEn(JUNGSUNG[jung]))
if (jong != 0) sb.append(mapKoToEn(JONGSUNG[jong]))
} else {
sb.append(mapKoToEn(char.toString()))
}
}
return sb.toString()
}
/**
* [검색 메인 함수]
* 이름(target) 검색어(query) 비교하여 매칭 여부를 반환합니다.
*/
fun match(target: String, query: String): Boolean {
if (query.isEmpty()) return true
val t = target.trim()
val q = query.trim()
// 1. 기본 포함 여부 (대소문자 무시)
if (t.contains(q, ignoreCase = true)) return true
// 2. 한글 초성 검색 (예: '카카오톡' -> 'ㅋㅋ')
// 검색어가 모두 한글 자음(초성)으로만 구성되어 있는지 확인
if (isAllChosung(q)) {
val targetChosung = makeChosung(t)
if (targetChosung.contains(q)) return true
}
// 3. 영한 발음 검색 (예: 영어 앱 'Instagram'을 한글 '인스타'로 검색)
// 검색어(한글)를 로마자로 변환하여 영어 앱 이름과 비교
if (isKorean(q) && isEnglish(t)) {
val romanQuery = hangulToRoman(q)
if (t.lowercase(Locale.getDefault()).contains(romanQuery)) return true
}
// 4. (선택사항) 영타 오타 보정 (예: '안녕'을 'dkssud'로 쳤을 때)
// 이미 EnToKo.kt가 있다면 그곳의 로직을 사용할 수도 있습니다.
val korFromEngKey = enToKo(q) // dkssud -> 안녕
if (t.contains(korFromEngKey, ignoreCase = true)) return true
return false
}
/**
* 문자열에서 초성만 추출합니다. (: "홍길동" -> "ㅎㄱㄷ")
*/
fun makeChosung(input: String): String {
val sb = StringBuilder()
for (char in input) {
val code = char.code
if (code in 0xAC00..0xD7AF) {
val choIndex = (code - 0xAC00) / 28 / 21
sb.append(CHOSUNG[choIndex])
} else {
sb.append(char) // 한글이 아니면 그대로 둠
}
}
return sb.toString()
}
/**
* 한글 문자열을 로마자(발음) 변환합니다. (: "인스타" -> "inseuta")
* 단순 매핑 방식이므로 완벽한 발음기호는 아니지만 검색용으로 유용합니다.
*/
fun hangulToRoman(input: String): String {
val sb = StringBuilder()
for (char in input) {
val code = char.code
if (code in 0xAC00..0xD7AF) {
val base = code - 0xAC00
val choIndex = base / 28 / 21
val jungIndex = (base / 28) % 21
val jongIndex = base % 28
sb.append(CHOSUNG_EN[choIndex])
sb.append(JUNGSUNG_EN[jungIndex])
// 종성은 앞 글자의 받침이 뒷 글자의 초성과 이어지는 연음 법칙 등을 고려하지 않고 단순 매핑
if (jongIndex > 0 && jongIndex < JONGSUNG_EN.size) {
sb.append(JONGSUNG_EN[jongIndex])
}
} else {
sb.append(char)
}
}
return sb.toString()
}
// 검색어가 초성(자음)으로만 이루어졌는지 확인
private fun isAllChosung(text: String): Boolean {
for (char in text) {
if (char.code !in 0x3131..0x314E) { // ㄱ ~ ㅎ 범위
return false
}
}
return true
}
private fun isKorean(text: String): Boolean {
for (char in text) {
if (char.code in 0xAC00..0xD7AF) return true
}
return false
}
private fun isEnglish(text: String): Boolean {
for (char in text) {
if (char.code in 0x41..0x5A || char.code in 0x61..0x7A) return true
}
return false
}
// 기존의 영타 -> 한타 변환 함수 (그대로 유지)
fun enToKo(input: String): String {
val sb = StringBuilder()
for (char in input) {
val idx = EN_KEY.indexOf(char)
if (idx != -1) sb.append(KO_KEY[idx]) else sb.append(char)
}
return sb.toString()
}
private val APP_NAME_DICT = mapOf(
"gemini" to "제미나이",
"gmail" to "지메일",
"instagram" to "인스타그램",
"youtube" to "유튜브",
"facebook" to "페이스북",
"chrome" to "크롬",
"google" to "구글",
"kakaotalk" to "카카오톡",
"kakao" to "카카오",
"naver" to "네이버",
"toss" to "토스",
"kb" to "국민",
"shinhan" to "신한",
"coupang" to "쿠팡",
"baemin" to "배달의민족",
"netflix" to "넷플릭스",
"zoom" to "",
"slack" to "슬랙",
"discord" to "디스코드",
"spotify" to "스포티파이",
// 추가 글로벌 앱
"tiktok" to "틱톡",
"whatsapp" to "왓츠앱",
"telegram" to "텔레그램",
"snapchat" to "스냅챗",
"pinterest" to "핀터레스트",
"x" to "엑스", // X (舊 Twitter)
"threads" to "스레즈",
"chatgpt" to "챗지피티",
"github" to "깃허브",
"gitlab" to "깃랩",
"notion" to "노션",
"figma" to "피그마",
"discord" to "디스코드",
"zoom" to "",
"messenger" to "메신저",
"googlemaps" to "구글맵스",
"googlephotos" to "구글포토",
"googlecalendar" to "구글캘린더",
"googlepay" to "구글페이",
// 국내 앱 / 서비스
"kakao_map" to "카카오맵",
"kakao_t" to "카카오티",
"kakaopay" to "카카오페이",
"baedalchon" to "배달의민족", // baemin alias
"kurly" to "컬리",
"marketkurly" to "컬리",
"ssg" to "이마트몰",
"emart24" to "이마트24",
"lotteon" to "롯데온",
"ssgpay" to "쓱페이",
"happybean" to "해피빈",
"wibee" to "위비",
"tonerd" to "토너먼트", // 예: 토너먼트 앱
"bubble" to "버블",
"papago" to "파파고",
"cashwalk" to "캐시워크",
"kakao_health" to "카카오헬스케어",
"minwon24" to "민원24",
"kbpay" to "KB페이",
"shinhan_sbank" to "신한은행",
"woori" to "우리",
"ibk" to "기업은행",
"nh" to "농협",
"kb_card" to "KB국민카드",
"shinhancard" to "신한카드",
"wooricard" to "우리카드",
"cardfactory" to "카드팩토리",
"magazine_n" to "매거진엔",
"cultureland" to "컬쳐랜드",
"happymoney" to "해피머니",
"smilepay" to "스마일페이",
"ssg_point" to "쓱포인트",
"kakaotaxi" to "카카오택시", // kakao_t alias
"kakao_bus" to "카카오버스",
"kakao_navigation" to "카카오내비",
// 게임·엔터 관련
"cyberpunk2077" to "사이버펑크2077",
"gta" to "GTA",
"pubg" to "배틀그라운드",
"pubgm" to "배틀그라운드 모바일",
"leagueoflegends" to "리그오브레전드",
"leagueoflegends_mobile" to "리그오브레전드 와일드 리프트",
"honkai" to "붕괴",
"genshin" to "원신",
"honkai_star_rail" to "붕괴 스타레일",
"miy" to "미야오츠츠미", // 예: 특정 게임, 필요 시 조정
"among_us" to "어몽 어스",
"minecraft" to "마인크래프트",
"genshin_impact" to "원신 임팩트",
"tiktok_live" to "틱톡 라이브",
"twitch" to "트위치",
"twitchtv" to "트위치티비",
"twitchapp" to "트위치앱",
// 음악·스트리밍
"soundcloud" to "사운드클라우드",
"deezer" to "디저",
"melon" to "멜론",
"flo" to "플로",
"bugs" to "벅스",
"vibe" to "바이브",
"genie" to "지니",
"youtube_music" to "유튜브 뮤직",
"spotify" to "스포티파이",
"pandora" to "판도라",
"apple_music" to "애플뮤직",
// 교통·공공
"kakao_bus" to "카카오버스",
"kakao_subway" to "카카오지하철",
"ktx" to "케이티엑스",
"korail" to "코레일",
"snackbar" to "스낵바", // 예: 카카오톡 플러스친구 등
)
// 영어 자음 -> 한글 초성 매핑
private val ENG_TO_CHO = mapOf(
'b' to "", 'c' to "", 'd' to "", 'f' to "", 'g' to "",
'h' to "", 'j' to "", 'k' to "", 'l' to "", 'm' to "",
'n' to "", 'p' to "", 'q' to "", 'r' to "", 's' to "",
't' to "", 'v' to "", 'w' to "", 'x' to "", 'y' to "", 'z' to ""
)
// 영어 모음 -> 한글 중성 매핑
private val ENG_TO_JUNG = mapOf(
'a' to "", 'e' to "", 'i' to "", 'o' to "", 'u' to "",
'y' to "" // y는 모음으로 쓰일 때도 있음
)
/**
* [영어 -> 한글 발음 변환 메인 함수]
* : "Instagram" -> "인스타그램" (사전 매칭)
* : "Test" -> "테스트" (규칙 변환)
*/
fun englishToHangul(text: String): String {
val lower = text.lowercase(Locale.getDefault())
// 1단계: 사전에 있는 단어인지 확인 (가장 정확)
APP_NAME_DICT[lower]?.let { return it }
// 2단계: 사전에 없다면 규칙 기반으로 변환 시도
return convertEngToKorRule(lower)
}
/**
* 규칙 기반 영한 변환기 (간이 버전)
* 자음+모음 -> 글자 조합
* 자음+자음 -> 'ㅡ' 추가
*/
private fun convertEngToKorRule(text: String): String {
val sb = StringBuilder()
var i = 0
val len = text.length
while (i < len) {
val curr = text[i]
// 영어가 아니면 그냥 통과
if (curr !in 'a'..'z') {
sb.append(curr)
i++
continue
}
// 자음인지 모음인지 확인
if (isVowel(curr)) {
// 모음으로 시작하면 'ㅇ' 붙여서 출력 (예: apple -> 애플)
val jung = ENG_TO_JUNG[curr] ?: ""
sb.append(combineHangul("", jung, ""))
i++
} else {
// 자음인 경우
val cho = ENG_TO_CHO[curr] ?: ""
// 다음 글자 확인
if (i + 1 < len) {
val next = text[i + 1]
if (isVowel(next)) {
// 자음 + 모음 -> 합체 (예: t + e -> 테)
val jung = ENG_TO_JUNG[next] ?: ""
// 다다음 글자가 받침이 될 수 있는지 확인 (간단한 종성 처리)
var jong = ""
if (i + 2 < len) {
val next2 = text[i + 2]
if (!isVowel(next2) && next2 != 'y') { // 자음이면 받침 후보
// 단, 그 뒤에 또 모음이 오면 받침이 아님 (예: te-s-t -> 's'는 받침 아님)
if (i + 3 >= len || !isVowel(text[i + 3])) {
jong = getJongsung(next2)
if (jong.isNotEmpty()) i++ // 받침으로 썼으니 인덱스 증가
}
}
}
sb.append(combineHangul(cho, jung, jong))
i += 2
} else {
// 자음 + 자음 -> 'ㅡ' 추가 (예: s + t -> 스)
sb.append(combineHangul(cho, "", ""))
i++
}
} else {
// 마지막이 자음으로 끝남 -> 'ㅡ' 추가 (예: ...t -> 트)
// (받침으로 넣을 수도 있지만, 검색용으로는 풀어서 쓰는게 매칭률이 높음)
sb.append(combineHangul(cho, "", ""))
i++
}
}
}
return sb.toString()
}
private fun isVowel(c: Char): Boolean {
return c == 'a' || c == 'e' || c == 'i' || c == 'o' || c == 'u'
}
// 종성(받침) 매핑 (제한적)
private fun getJongsung(c: Char): String {
return when (c) {
'n' -> ""; 'm' -> ""; 'l' -> ""; 'k' -> ""; 'p' -> ""; 't' -> ""; 'g' -> "" // ng 약식
else -> ""
}
}
// 초성, 중성, 종성 문자열을 받아 한글 한 글자로 합치는 함수
private fun combineHangul(cho: String, jung: String, jong: String): String {
val choIdx = CHOSUNG.indexOf(cho)
val jungIdx = JUNGSUNG.indexOf(jung)
val jongIdx = JONGSUNG.indexOf(jong)
if (choIdx == -1 || jungIdx == -1) return cho // 실패 시 그냥 자음 반환
// 한글 유니코드 공식: 0xAC00 + (초성 * 21 + 중성) * 28 + 종성
val code = 0xAC00 + (choIdx * 21 + jungIdx) * 28 + (if (jongIdx != -1) jongIdx else 0)
return code.toChar().toString()
}
}

View File

@ -10,6 +10,7 @@ import bums.lunatic.launcher.BuildConfig
import bums.lunatic.launcher.model.AppInfo
import bums.lunatic.launcher.utils.AlphabetToChosungMap
import bums.lunatic.launcher.utils.JamoUtils
import bums.lunatic.launcher.utils.SimpleTransliterater
import io.realm.kotlin.types.RealmObject
import java.text.Normalizer
import java.util.regex.Pattern
@ -43,6 +44,7 @@ class AppInfoGetter : BaseGetter {
result.add(AppInfo().apply {
this.appName = appName
this.searchIndex = SimpleTransliterater.makeSearchIndex(appName)
this.pkgName = pkgName
this.category = getCategory(ri.activityInfo.applicationInfo.category)
this.alphaCho = AlphabetToChosungMap.getCho(appName)

View File

@ -193,9 +193,9 @@ object WorkersDb {
val curDay = calendar.get(Calendar.DAY_OF_MONTH)
val curDow = calendar.get(Calendar.DAY_OF_WEEK)
val curHour = calendar.get(Calendar.HOUR_OF_DAY)
val ONE_DAY_TIME = 24 * 60 * 60 * 1000
// 최근 3개월 데이터만 조회 (너무 오래된 데이터는 노이즈가 됨)
val threeMonthsAgo = System.currentTimeMillis() - (365L * 24 * 60 * 60 * 1000)
val threeMonthsAgo = System.currentTimeMillis() - (365L * ONE_DAY_TIME)
// 쿼리: 최근 데이터만 가져와서 메모리에서 계산 (복잡한 가중치는 메모리 연산이 빠름)
val logs = realm.query<AppUsageLog>("timestamp > $0", threeMonthsAgo).find()
@ -221,7 +221,7 @@ object WorkersDb {
if (log.month == curMonth && log.dayOfMonth == curDay) score += 10.0
// 5. 최신성 가중치 (최근 기록일수록 점수 높게)
val daysAgo = (System.currentTimeMillis() - log.timestamp) / (24 * 60 * 60 * 1000)
val daysAgo = (System.currentTimeMillis() - log.timestamp).toDouble() / ONE_DAY_TIME
val decay = 1.0 / (daysAgo + 1) // 오늘이면 1, 9일전이면 0.1
// 최종 점수 누적

View File

@ -7,16 +7,6 @@
android:background="@drawable/rounded_bg_top"
android:paddingTop="10dp">
<View
android:id="@+id/drag_handle"
android:layout_width="40dp"
android:layout_height="4dp"
android:background="#E0E0E0"
android:layout_marginTop="8dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/searchInput"
android:layout_width="0dp"
@ -29,7 +19,7 @@
android:hint="앱 검색"
android:imeOptions="actionSearch"
android:singleLine="true"
app:layout_constraintTop_toBottomOf="@id/drag_handle"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toLeftOf="@+id/search_google"/>
@ -140,6 +130,18 @@
android:background="@color/black"
android:gravity="center_vertical"
>
<androidx.appcompat.widget.AppCompatSpinner
android:paddingLeft="5dp"
android:paddingRight="5dp"
android:layout_marginLeft="6dp"
android:layout_marginRight="6dp"
android:layout_marginTop="1dp"
android:layout_marginBottom="1dp"
android:id="@+id/categorySpinner"
android:layout_width="wrap_content"
android:layout_height="33dp"
android:spinnerMode="dropdown"
android:background="@drawable/base_bg"/>
<TextView
style="@style/SearchAccs"
app:autoSizeTextType="uniform"

View File

@ -21,12 +21,25 @@
android:padding="@dimen/eight"
android:inputType="textNoSuggestions"
/>
<TextView
android:id="@+id/alterName"
app:layout_constraintTop_toBottomOf="@id/appName"
android:layout_width="wrap_content"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
android:layout_height="wrap_content"
android:minWidth="@dimen/zero"
android:textSize="20dp"
android:gravity="center"
android:padding="@dimen/eight"
android:inputType="textNoSuggestions"
/>
<TextView
android:id="@+id/totalTouch"
android:layout_margin="20dp"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintTop_toBottomOf="@id/appName"
app:layout_constraintTop_toBottomOf="@id/alterName"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:gravity="center_vertical|left"