Vibe coding으로 웹 기반의 응용을 많이 만들지만 Android 앱도 만들 수 있다.

 

- PRD 작성: PRD라고 적었지만, Product라기 보다는 기능이 가져야 하는 요구 사항을 정리한다. PRD는 사실 이 보다는 더 넓은 개념에서 제품에 대해 정리한 부분이기는 하다.

- Empy Project 만들기

- PRD 전달하고 코드 생성하기

- 테스트가 가능해지는 시점에서 빌드하면서 Feedback하고 수정

 

PRD 작성

Wireframe

위와 같은 UI Flow 혹은 Wireframe을 손으로 그렸다. 이 부분에 대한 설명은 다음과 같이 했다.

Android로 첨부한 Wireframe의 앱의 PRD를 작성해줘.
- 첫 화면에서는 버튼이 있어서, 클릭하면 Video Picker가 실행이 돼.
- Video Picker에서 비디오를 선택하면, 가로 화면으로 비디오를 재생해줘.
- 비디오 재생 화면의 오른쪽 상단에 X표 버튼이 있어서, 누르면 첫 화면으로 돌아가.
- 첫 화면에서는 기존에 재생한 비디오 목록을 보여줘. 최대 10개까지 보여줘.

 

아래는 생성된 PRD에서 일부 수정한 내용이다. 예를 들면, 단순화 하기 이해 Room DB와 SharedPreference 중에서 후자를 선택해서 단순화 하고, Thumbnail 추출은 MediaExtractor로 변경하였다.

 

# 📄 Product Requirements Document (PRD)

📱 App Name: VideoPlay

## 🎯 목적 (Purpose)
사용자가 휴대폰에 저장된 비디오를 선택해 전체화면(가로모드)으로 재생하고, 최근 재생한 비디오 목록을 쉽게 다시 접근할 수 있도록 하는 Android 앱.

## 🧑‍💻 주요 기능 (Key Features)
1. 홈 화면 (Home Screen)
구성 요소:
[재생 시작 버튼] : 비디오 선택을 위한 버튼
[최근 재생 목록] : 최대 10개의 최근 재생한 비디오 파일 표시

기능:
사용자가 버튼을 누르면 시스템 Video Picker 호출
최근 재생 목록은 가장 최근 것이 상단에 표시되며, 최대 10개 저장
각 목록은 썸네일 + 파일명 표시

2. 비디오 선택 (Video Picker)
Android 시스템의 파일 선택기 사용 (Intent.ACTION_PICK or Intent.ACTION_OPEN_DOCUMENT)
비디오 MIME 타입 필터링 (video/*)
사용자가 선택한 파일 URI를 앱으로 반환

3. 비디오 재생 화면 (Video Player Screen)
가로 화면 고정 (Landscape)
전체 화면 비디오 재생
오른쪽 상단에 [X] 버튼 배치
누르면 재생 중단 및 홈 화면으로 복귀
재생된 파일은 최근 재생 목록에 추가
기존 목록에 이미 있는 경우, 맨 앞으로 이동

10개를 초과하면 가장 오래된 항목 제거

## 🔁 사용자 흐름 (User Flow)
앱 시작
→ 홈 화면 표시

[재생 시작] 버튼 클릭
→ 시스템 Video Picker 실행

비디오 선택
→ 비디오 재생 화면 전환 (가로 모드)

비디오 재생
→ [X] 버튼 클릭 시 홈으로 복귀

홈화면
→ 최근 재생한 목록에 새 항목 반영됨

## 📱 UI 구성 (UI Components)
### 📄 홈 화면
Button - "비디오 선택"
RecyclerView (수직 스크롤)
아이템 구성: 썸네일, 파일명

### 🎞️ 비디오 재생 화면
VideoView or ExoPlayer
전체 화면 (Landscape)
ImageButton (오른쪽 상단) - X 아이콘

### 💾 데이터 저장 (Storage)
최근 재생한 비디오 리스트
최대 10개
SharedPreferences
저장 항목: URI, 파일명, 썸네일 경로(optional)

### ⚙️ 기술 스택 (Tech Stack)
Language: Kotlin
UI Framework: Android View System / Jetpack Compose (선택)
Media Player: ExoPlayer
저장소: SharedPreferences

### 📝 비고 (Notes)
썸네일은 MediaExtractor에서 추출
재생 중 화면 회전은 고정 (android:screenOrientation="landscape")

 

Cursor로 작업

이 후 작업은 Cursor에서 작업하기 위한 준비 작업과 코드 생성 및 테스트 방법이다. 물론, Cursor에서도 CLI로 gradle build하고 adb로 설치하여 테스트하는 것도 가능하겠지만, Android Studio가 이 작업을 GUI로 처리해 주므로 더 편리할 수도 있다.

 

- Android Studio에 빈 Project를 만든다.

- Cursor에서 이 Project Folder를 연다.

- PRD 전달하고 코드 생성하기

- 테스트가 가능해지는 시점에서 빌드하면서 Feedback하고 수정

 

테스트 시점에서는 Android Studio의 logcat의 경우, App과 관련된 log만 필터 하기 때문에 정말 필요한 정보를 Cursor에 피드백하기 좋다. 아래는 완료된 Video Player 구현 데모 영상이다.

 

 

마무리

ChatGPT와 같은 LLM으로 코드를 생성하면서 작업도 충분히 가능하지만, Cursor와 같은 IDE의 경우는 피드백 루프를 단순화 해주어 생산성을 더 높여 준다. 여기서 생성한 코드는 아니지만, 유사한 작업을 한 결과물을 github 링크도 남겨 둔다.

 

https://github.com/blcktgr73/ExoVibe

'Agentic Coding' 카테고리의 다른 글

Claude Code의 확장 메커니즘  (0) 2025.11.01
[Android App] BookPlay: PDF 책읽기  (2) 2025.10.08
Claude Code Meetup@ELUOCNC  (0) 2025.09.21
Claude Code vs Codex  (2) 2025.09.07
[React+Vite+Tailwind v4] Backend 기본 설정 요약  (0) 2025.08.27

문제 정의

운전과 같이 사용자가 휴대폰을 사용하지 말아야 하는 상황에서 사용자가 휴대폰을 사용하는 내역을 파악하는 기능을 개발하고자 하는 필요가 있었다. 그래서, 다음과 같은 Prompt로 가능한 방법에 대해서 제안을 해달라고 요청했다.

"너는 pytorch 및 tensorflow의 전문가이고, 동영상 처리에 대한 전문가야. Android에 대해서도 잘 알고 있는 전문가야. 휴대폰의 사용자가 휴대폰 사용하는 동안 어떤 어플리케이션을 사용하고 있는지 알아 내는 모듈을 개발하려고해. 이를 알아내는 방법을 여러가지 제안해줘"

이에 대해서 다음 사항들을 제안했다.

  1. Accessibility Services 활용
  2. UsageStatsManager 활용
  3. Foreground Service 및 Activity Recognition API
  4. Logcat 활용 (루팅된 기기)
  5. Screen Overlay 탐지 (Android 10 이하)
  6. Custom System-level API 개발 (OEM 권한 필요)
  7. ML 기반 비디오/스크린 분석 (실험적)

특히, 다음 제안과 권장 사항도 제공받았다.

  • 보안 및 개인정보 보호 우선: 민감한 정보를 다루는 작업이므로 사용자 동의를 확실히 받는 것이 중요합니다.
  • 기술적 가능성 검토: Android 버전, 권한 정책, 배터리 소모 등을 면밀히 분석하세요.
  • 효율적인 구현: UsageStatsManager와 Accessibility Service의 조합을 1차적으로 추천합니다.

초기 Prompting

제안 받은 데로, UsageStatsManager를 사용하려고 했고, 조금 더 쉽게 접근하기 위해서 아래 Prompting으로 시작했다. 특히, 우리 협력 팀 중 하나가 유사한 기능을 UsageStatsManager를 사용하여 구현하였다.

 

"너는 android application 개발자야. 다음 요구 사항을 만족하는 application을 만들어줘. 추가적으로 필요한 정보가 있다면, 확인하고 제안해줘. 프로그램을 작성하는 순서를 상세히 설명해줘.

  1. MainActivity를 가지고 있어
  2. LifecycleService를 가지고 있어. MainActivity에서 이 Service를 구동해
  3. MainActivity에는 Start 버튼을 가지고 있어. 이 버튼을 클릭하면, Service 동작 시간을 측정 시작해. 그리고, Start 버튼이 Stop 버튼으로 변경돼.
  4. 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을 돌리는 것이 중요하다는 것을 다시금 깨닫게 된다. 중간에 다른 인터페이스도 사용해보고, 테스트하면서 알게된 부분을 가지고 계속 개선하면서 현재까지 결과를 얻었다. 이 코드를 가지고 팀원들에게 피드백을 받으면서 진행하는 것이 필요한 부분이다.

+ Recent posts