문제 정의
운전과 같이 사용자가 휴대폰을 사용하지 말아야 하는 상황에서 사용자가 휴대폰을 사용하는 내역을 파악하는 기능을 개발하고자 하는 필요가 있었다. 그래서, 다음과 같은 Prompt로 가능한 방법에 대해서 제안을 해달라고 요청했다.
"너는 pytorch 및 tensorflow의 전문가이고, 동영상 처리에 대한 전문가야. Android에 대해서도 잘 알고 있는 전문가야. 휴대폰의 사용자가 휴대폰 사용하는 동안 어떤 어플리케이션을 사용하고 있는지 알아 내는 모듈을 개발하려고해. 이를 알아내는 방법을 여러가지 제안해줘"
이에 대해서 다음 사항들을 제안했다.
- Accessibility Services 활용
- UsageStatsManager 활용
- Foreground Service 및 Activity Recognition API
- Logcat 활용 (루팅된 기기)
- Screen Overlay 탐지 (Android 10 이하)
- Custom System-level API 개발 (OEM 권한 필요)
- ML 기반 비디오/스크린 분석 (실험적)
특히, 다음 제안과 권장 사항도 제공받았다.
- 보안 및 개인정보 보호 우선: 민감한 정보를 다루는 작업이므로 사용자 동의를 확실히 받는 것이 중요합니다.
- 기술적 가능성 검토: Android 버전, 권한 정책, 배터리 소모 등을 면밀히 분석하세요.
- 효율적인 구현: UsageStatsManager와 Accessibility Service의 조합을 1차적으로 추천합니다.
초기 Prompting
제안 받은 데로, UsageStatsManager를 사용하려고 했고, 조금 더 쉽게 접근하기 위해서 아래 Prompting으로 시작했다. 특히, 우리 협력 팀 중 하나가 유사한 기능을 UsageStatsManager를 사용하여 구현하였다.
"너는 android application 개발자야. 다음 요구 사항을 만족하는 application을 만들어줘. 추가적으로 필요한 정보가 있다면, 확인하고 제안해줘. 프로그램을 작성하는 순서를 상세히 설명해줘.
- MainActivity를 가지고 있어
- LifecycleService를 가지고 있어. MainActivity에서 이 Service를 구동해
- MainActivity에는 Start 버튼을 가지고 있어. 이 버튼을 클릭하면, Service 동작 시간을 측정 시작해. 그리고, Start 버튼이 Stop 버튼으로 변경돼.
- MainActivity의 Stop 버튼을 클릭하면, 휴대폰에서 사용자가 다른 어플리케이션을 사용한 내용을 정리해서 알려주는 기능을 가지고 있어. 이 부분은 UsageStatsManager에서 사용 이력을 확인할 수 있어."
제공받은 코드로 테스트를 했지만, Service로 구동시키는 것이 쉽지 않아, MainActivity에서 버튼으로 시작하고 Stop으로 마무리 하도록 했다.
추가 진행
"UsageStatsHelper의 getUsageStats의 기능을 개선해줘. MainActivity에서 Start 버턴을 누른 시간 부터 Stop 버튼 누른 시간 사이에 사용된 앱에 대해서 정보를 출력해줘. 사용시간이 0 이상인 package 정보만 출력해줘. 특정 package의 경우는 제외할 수 있도록 해줘."
UsageStatsHelper.kt 을 다음과 같이 받았다.
import android.app.usage.UsageStats
import android.app.usage.UsageStatsManager
import android.content.Context
import android.content.pm.PackageManager
import java.text.SimpleDateFormat
import java.util.*
object UsageStatsHelper {
// 특정 패키지를 제외할 수 있도록 리스트를 설정
private val excludedPackages = setOf(
"com.android.systemui" // 시스템 UI는 보통 제외하는 것이 좋음
)
fun getUsageStats(context: Context, startTime: Long, endTime: Long): String {
val usageStatsManager =
context.getSystemService(Context.USAGE_STATS_SERVICE) as UsageStatsManager
val usageStatsList: List<UsageStats> = usageStatsManager.queryUsageStats(
UsageStatsManager.INTERVAL_BEST, startTime, endTime
)
if (usageStatsList.isEmpty()) {
return "사용 기록 없음. 권한이 필요할 수 있습니다."
}
val packageManager = context.packageManager
val sdf = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
val result = StringBuilder()
// 사용 시간이 0보다 크고 제외 대상이 아닌 앱만 필터링
val filteredStats = usageStatsList
.filter { it.totalTimeInForeground > 0 && it.packageName !in excludedPackages }
.sortedByDescending { it.totalTimeInForeground } // 가장 많이 사용한 앱 순으로 정렬
if (filteredStats.isEmpty()) {
return "해당 시간 동안 사용된 앱이 없습니다."
}
filteredStats.forEach { stat ->
val packageName = stat.packageName
val appName = getAppName(packageManager, packageName)
val lastUsed = sdf.format(Date(stat.lastTimeUsed))
val totalTime = formatTime(stat.totalTimeInForeground)
result.append("$appName ($packageName) 사용 시간: $totalTime (마지막 사용: $lastUsed)\n")
}
return result.toString()
}
// 패키지명으로부터 앱 이름을 가져오는 함수
private fun getAppName(packageManager: PackageManager, packageName: String): String {
return try {
val applicationInfo = packageManager.getApplicationInfo(packageName, 0)
packageManager.getApplicationLabel(applicationInfo).toString()
} catch (e: PackageManager.NameNotFoundException) {
packageName // 앱 이름을 가져오지 못하면 패키지명 반환
}
}
// 밀리초 단위를 "시간:분:초" 형식으로 변환하는 함수
private fun formatTime(milliseconds: Long): String {
val seconds = milliseconds / 1000
val hours = seconds / 3600
val minutes = (seconds % 3600) / 60
val remainingSeconds = seconds % 60
return String.format("%02d:%02d:%02d", hours, minutes, remainingSeconds)
}
}
실재로 App 사용된 시간 파악
여러가지 테스트를 하고 알게된 것도 많았다. 가장 중요한 이슈는 우리가 파악하고자 하는 기간 동안에 실재로 app이 사용된 duration보다 더 길게 나오는 이슈가 있었다.
"startTime과 endTime의 duration보다 실재 사용한 duration이 더 길게 나오는 것으로 보여. 위의 queryUsageStats의 lastUsedTime을 이용해서 startTime과 endTime 사이 사용된 앱을 찾아내줘. 그리고, 다른 방법으로 startTime과 endTime 사이에서 실재로 app이 사용된 시간을 파악해서 출력할 수 있도록 getUsageStats 함수를 업데이트 해줘."
import android.app.usage.UsageStats
import android.app.usage.UsageStatsManager
import android.content.Context
import android.content.pm.PackageManager
import java.text.SimpleDateFormat
import java.util.*
object UsageStatsHelper {
// 특정 패키지를 제외할 수 있도록 리스트 설정
private val excludedPackages = setOf(
"com.android.systemui" // 시스템 UI 제외
)
fun getUsageStats(context: Context, startTime: Long, endTime: Long): String {
val usageStatsManager =
context.getSystemService(Context.USAGE_STATS_SERVICE) as UsageStatsManager
val usageStatsList: List<UsageStats> = usageStatsManager.queryUsageStats(
UsageStatsManager.INTERVAL_BEST, startTime, endTime
)
if (usageStatsList.isEmpty()) {
return "사용 기록 없음. (데이터가 업데이트되지 않았을 수 있음)"
}
val packageManager = context.packageManager
val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
val result = StringBuilder()
// ✅ `lastTimeUsed`를 기반으로 `startTime ~ endTime` 사이에 실제 사용된 앱만 필터링
val filteredStats = usageStatsList.filter {
it.totalTimeInForeground > 0 &&
it.lastTimeUsed in startTime..endTime && // 마지막 사용 시간이 startTime ~ endTime 사이에 있어야 함
it.packageName !in excludedPackages
}.sortedByDescending { it.totalTimeInForeground } // 가장 많이 사용한 앱 순으로 정렬
if (filteredStats.isEmpty()) {
return "해당 시간 동안 사용된 앱이 없습니다."
}
filteredStats.forEach { stat ->
val packageName = stat.packageName
val appName = getAppName(packageManager, packageName)
val lastUsed = sdf.format(Date(stat.lastTimeUsed))
// ✅ 실재로 `startTime ~ endTime` 사이에서 사용된 시간을 계산
val actualUsageTime = calculateActualUsageTime(stat, startTime, endTime)
val formattedTime = formatTime(actualUsageTime)
result.append("$appName ($packageName) 사용 시간: $formattedTime (마지막 사용: $lastUsed)\n")
}
return result.toString()
}
// ✅ 앱이 `startTime ~ endTime` 사이에서 실재로 사용된 시간을 계산하는 함수
private fun calculateActualUsageTime(stat: UsageStats, startTime: Long, endTime: Long): Long {
val lastUsed = stat.lastTimeUsed
val totalTime = stat.totalTimeInForeground
// ✅ `lastTimeUsed`가 `startTime ~ endTime`에 포함된 경우
return if (lastUsed in startTime..endTime) {
// 앱이 `startTime` 이전부터 실행 중이었다면 `startTime`부터 사용 시간 계산
val adjustedStartTime = maxOf(startTime, lastUsed - totalTime)
val adjustedEndTime = minOf(endTime, lastUsed)
adjustedEndTime - adjustedStartTime
} else {
0L // 사용되지 않은 경우 0 반환
}
}
// 패키지명으로부터 앱 이름을 가져오는 함수
private fun getAppName(packageManager: PackageManager, packageName: String): String {
return try {
val applicationInfo = packageManager.getApplicationInfo(packageName, 0)
packageManager.getApplicationLabel(applicationInfo).toString()
} catch (e: PackageManager.NameNotFoundException) {
packageName // 앱 이름을 가져오지 못하면 패키지명 반환
}
}
// 밀리초 단위를 "시간:분:초" 형식으로 변환하는 함수
private fun formatTime(milliseconds: Long): String {
val seconds = milliseconds / 1000
val hours = seconds / 3600
val minutes = (seconds % 3600) / 60
val remainingSeconds = seconds % 60
return String.format("%02d:%02d:%02d", hours, minutes, remainingSeconds)
}
}
두 번째 개선
"calculateActualUsageTime을 UsageStatsManager.queryEvents(startTime, endTime)를 사용하여 앱의 실행 이벤트(ACTIVITY_RESUMED, ACTIVITY_PAUSED)를 추적해서 계산해줘."
import android.app.usage.UsageEvents
import android.app.usage.UsageStats
import android.app.usage.UsageStatsManager
import android.content.Context
import android.content.pm.PackageManager
import java.text.SimpleDateFormat
import java.util.*
object UsageStatsHelper {
// 특정 패키지를 제외할 수 있도록 리스트 설정
private val excludedPackages = setOf(
"com.android.systemui" // 시스템 UI 제외
)
fun getUsageStats(context: Context, startTime: Long, endTime: Long): String {
val usageStatsManager =
context.getSystemService(Context.USAGE_STATS_SERVICE) as UsageStatsManager
val usageStatsList: List<UsageStats> = usageStatsManager.queryUsageStats(
UsageStatsManager.INTERVAL_BEST, startTime, endTime
)
if (usageStatsList.isEmpty()) {
return "사용 기록 없음. (데이터가 업데이트되지 않았을 수 있음)"
}
val packageManager = context.packageManager
val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
val result = StringBuilder()
// ✅ `lastTimeUsed`를 기반으로 `startTime ~ endTime` 사이에 실제 사용된 앱만 필터링
val filteredStats = usageStatsList.filter {
it.totalTimeInForeground > 0 &&
it.lastTimeUsed in startTime..endTime && // 마지막 사용 시간이 startTime ~ endTime 사이에 있어야 함
it.packageName !in excludedPackages
}.sortedByDescending { it.totalTimeInForeground } // 가장 많이 사용한 앱 순으로 정렬
if (filteredStats.isEmpty()) {
return "해당 시간 동안 사용된 앱이 없습니다."
}
filteredStats.forEach { stat ->
val packageName = stat.packageName
val appName = getAppName(packageManager, packageName)
val lastUsed = sdf.format(Date(stat.lastTimeUsed))
// ✅ `queryEvents()`를 사용하여 실제 사용된 시간을 계산
val actualUsageTime = calculateActualUsageTime(context, packageName, startTime, endTime)
val formattedTime = formatTime(actualUsageTime)
result.append("$appName ($packageName) 사용 시간: $formattedTime (마지막 사용: $lastUsed)\n")
}
return result.toString()
}
// ✅ `queryEvents()`를 사용하여 앱의 실행 이벤트 추적
private fun calculateActualUsageTime(context: Context, packageName: String, startTime: Long, endTime: Long): Long {
val usageStatsManager =
context.getSystemService(Context.USAGE_STATS_SERVICE) as UsageStatsManager
val events = usageStatsManager.queryEvents(startTime, endTime)
val event = UsageEvents.Event()
var totalUsageTime = 0L
var lastResumedTime: Long? = null
// 이벤트 스트림을 읽어가면서 ACTIVITY_RESUMED 및 ACTIVITY_PAUSED를 분석
while (events.hasNextEvent()) {
events.getNextEvent(event)
if (event.packageName == packageName) {
when (event.eventType) {
UsageEvents.Event.ACTIVITY_RESUMED -> {
lastResumedTime = event.timeStamp
}
UsageEvents.Event.ACTIVITY_PAUSED -> {
if (lastResumedTime != null) {
val usageDuration = event.timeStamp - lastResumedTime
if (usageDuration > 0) {
totalUsageTime += usageDuration
}
}
lastResumedTime = null
}
}
}
}
return totalUsageTime
}
// 패키지명으로부터 앱 이름을 가져오는 함수
private fun getAppName(packageManager: PackageManager, packageName: String): String {
return try {
val applicationInfo = packageManager.getApplicationInfo(packageName, 0)
packageManager.getApplicationLabel(applicationInfo).toString()
} catch (e: PackageManager.NameNotFoundException) {
packageName // 앱 이름을 가져오지 못하면 패키지명 반환
}
}
// 밀리초 단위를 "시간:분:초" 형식으로 변환하는 함수
private fun formatTime(milliseconds: Long): String {
val seconds = milliseconds / 1000
val hours = seconds / 3600
val minutes = (seconds % 3600) / 60
val remainingSeconds = seconds % 60
return String.format("%02d:%02d:%02d", hours, minutes, remainingSeconds)
}
}
결과의 포맷 정리
사용한 앱들의 정리를 위해서 Map형태로 정리하기로 하였다.
"Application 사용시간을 확인할 때, getUsageStats의 결과 물로 application 이름, package 이름 그리고 각 앱의 사용 시작, 사용 끝 시간을 포함하는 Map형태로 반환되도록 구성해줘."
import android.app.usage.UsageEvents
import android.app.usage.UsageStatsManager
import android.content.Context
import android.content.pm.PackageManager
import java.text.SimpleDateFormat
import java.util.*
object UsageStatsHelper {
// 특정 패키지를 제외할 수 있도록 리스트 설정
private val excludedPackages = setOf(
"com.android.systemui" // 시스템 UI 제외
)
/**
* 📌 사용된 애플리케이션 정보를 반환하는 함수
* @return Map<String, List<Pair<Long, Long>>>
* - Key: 앱 이름 (패키지 이름 포함)
* - Value: (사용 시작 시간, 사용 종료 시간) 리스트
*/
fun getUsageStats(context: Context, startTime: Long, endTime: Long): Map<String, List<Pair<Long, Long>>> {
val usageStatsManager =
context.getSystemService(Context.USAGE_STATS_SERVICE) as UsageStatsManager
val packageManager = context.packageManager
val result = mutableMapOf<String, MutableList<Pair<Long, Long>>>()
// ✅ `queryEvents()`를 사용하여 ACTIVITY_RESUMED 및 ACTIVITY_PAUSED 추적
val usageEvents = usageStatsManager.queryEvents(startTime, endTime)
val event = UsageEvents.Event()
val activeApps = mutableMapOf<String, Long>() // 현재 실행 중인 앱 저장
while (usageEvents.hasNextEvent()) {
usageEvents.getNextEvent(event)
val packageName = event.packageName
if (packageName in excludedPackages) continue // 제외할 패키지 필터링
val appName = getAppName(packageManager, packageName)
when (event.eventType) {
UsageEvents.Event.ACTIVITY_RESUMED -> {
activeApps[packageName] = event.timeStamp // 사용 시작 시간 저장
}
UsageEvents.Event.ACTIVITY_PAUSED -> {
val startTimestamp = activeApps.remove(packageName) ?: continue // 시작 시간이 없으면 패스
val endTimestamp = event.timeStamp
result.getOrPut("$appName ($packageName)") { mutableListOf() }
.add(Pair(startTimestamp, endTimestamp)) // 시작, 종료 시간 저장
}
}
}
return result
}
// 패키지명으로부터 앱 이름을 가져오는 함수
private fun getAppName(packageManager: PackageManager, packageName: String): String {
return try {
val applicationInfo = packageManager.getApplicationInfo(packageName, 0)
packageManager.getApplicationLabel(applicationInfo).toString()
} catch (e: PackageManager.NameNotFoundException) {
packageName // 앱 이름을 가져오지 못하면 패키지명 반환
}
}
}
마무리 하며...
이전에도 설명했듯이, 진행하면서 학습이 발생하기 때문에 여러 iteration을 돌리는 것이 중요하다는 것을 다시금 깨닫게 된다. 중간에 다른 인터페이스도 사용해보고, 테스트하면서 알게된 부분을 가지고 계속 개선하면서 현재까지 결과를 얻었다. 이 코드를 가지고 팀원들에게 피드백을 받으면서 진행하는 것이 필요한 부분이다.
'Vibe Coding' 카테고리의 다른 글
AI 시대의 학습: 자비스와 간달프 사이에서 (1) | 2025.06.24 |
---|---|
기술 덕후의 아침 루틴 만들기: Synology, Python, Obsidian 연동기 (3) | 2025.06.18 |
Prompt 하나로 개발이 된다? Vibe Coding 체험기 (7) | 2025.06.11 |
Vibe Coding: LLM을 이용한 개발 (0) | 2025.04.02 |