ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 사용자 수면 시간 추적하기
    Android 2025. 8. 31. 21:15

     

    안녕하세요. 오늘은 사용자의 수면 시간을 추적하는 원리에 대해 한번 소개해볼려고 합니다!

    1. 사용자가 잠든 시각 추적하기

    시스템 정보로부터 사용자가 잠든 시간을 추적해 볼 수 있습니다. 하나씩 살펴보겠습니다!

    1-1. 매니저 받아오기/사용 권한 받아오기

    val usageStatsManager = getSystemService(USAGE_STATS_SERVICE) as UsageStatsManager

     

    안드로이드에서 getSystemService() 호출할 때, 어떤 종류의 시스템 서비스 객체를 얻고 싶은지 문자열 상수로 지정해줘야 합니다. USAGE_STATS_SERVICE 상수를 통해 앱 사용 통계 관련 서비스를 요청합니다. 이때, UsageStatsManager는 안드로이드에서 앱 사용 기록, 화면 사용 시간 등 통계 데이터를 제공하는 클래스입니다. 특정 기간 동안 어떤 앱이 얼마나 사용됐는지 조회하거나, 앱 사용 빈도나 최근 사용 시간을 확인하여 앱 사용 패턴을 분석할 수 있게 됩니다.

     

    이러한 비교적 특별한 권한을 앱이 가지려면 사용자로부터 그 권한을 요청하여 받아와야합니다.

    <uses-permission
            android:name="android.permission.PACKAGE_USAGE_STATS"
            tools:ignore="ProtectedPermissions" />

     

    먼저 manifest 에 요청할 권한을 등록해야 합니다. 다른 권한 요청과 달리 tools:ignore 이 붙기에 일반 접근 권한이 아닌 특별 접근 권한임을 알 수 있습니다.

    if (!checkUsageAccessPermission(context)) {
        val dialog = AlertDialog.Builder(context).apply {
            setTitle("권한 요청 다이얼로그")
            setMessage("사용자의 수면시간 정보를 제공하기 위해선 사용 정보 접근 권한이 필요합니다. 설정에 들어가서 권한을 허용해주세요.")
            setPositiveButton("설정 이동") { _, _ ->
                val intent = Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS)
                startActivity(intent)
            }
            setCancelable(false)
            create()
        }
        dialog.show()
    }
    
    // 앱 사용 시간 권한 활성 여부를 확인하는 함수
    private fun checkUsageAccessPermission(context: Context): Boolean {
        val appOpsManager = context.getSystemService(APP_OPS_SERVICE) as AppOpsManager
        val mode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            // Android 10 이상
            appOpsManager.unsafeCheckOpNoThrow(
                AppOpsManager.OPSTR_GET_USAGE_STATS,
                android.os.Process.myUid(),
                context.packageName
            )
        } else {
            // Android 10 미만
            appOpsManager.checkOpNoThrow(
                AppOpsManager.OPSTR_GET_USAGE_STATS,
                android.os.Process.myUid(),
                context.packageName
            )
        }
        return mode == AppOpsManager.MODE_ALLOWED
    }

     

    먼저 권한이 있는지 확인합니다. 이때 if else 문을 통해 안드로이드 버전 호환성을 관리합니다. 안드로이드 10 이상에서 사용되는 unsafeCheckOpNoThrow 메소드가 안드로이드 10 미만보다 좀 더 보안성이 좋다고 합니다. 확인하고 싶은 접근 권한, 앱 고유 UID, 앱 패키지 이름을 인자로 지정하면 관련 상태(모드)를 알아낼 수 있는데, MODE_ALLOWED 와 비교해서 권한이 있는 지 불리언 값을 반환합니다.

     

    만약에 권한이 없다면 다이얼로그 UI를 통해 사용자가 직접 설정에서 권한을 킬 수 있도록 유도합니다.

    1-2. 잠든 시간대 지정하기

    val sleepCalendar = Calendar.getInstance().apply {
        // 오늘 날짜
        set(Calendar.HOUR_OF_DAY, 4)
        set(Calendar.MINUTE, 0)
        set(Calendar.SECOND, 0)
        set(Calendar.MILLISECOND, 0)
    }
    val sleepTrackingEndTime = sleepCalendar.timeInMillis // 04:00
    sleepCalendar.add(Calendar.HOUR_OF_DAY, -8) // 어제 날짜
    val sleepTrackingStartTime = sleepCalendar.timeInMillis // 20:00

     

    이제 권한을 통해 받아온 앱 사용 통계 데이터를 필요한 만큼 추출해야 합니다. 잠든 시간을 추적하려면 사용자가 어떤 시간대에 잠이 들었는지 기간 정보가 필요합니다. 저는 오후 8시부터 새벽 4시 사이에는 잠들 것이라 가정했고, 시간 변수를 millisecond 단위로 얻기 위해 Calendar 객체를 사용했습니다. 이 방법의 단점은 오후 8시 이전에 잠이 들거나 새벽 4시 이후에 잠이 드는 경우는 잠든 시간을 제대로 측정할 수 없습니다😓 (이러한 단점을 극복할 수 있는 더 나은 방법이 있다면 댓글로 적어주세요)

    1-3. 이벤트 받아오기(SCREEN_NON_INTERACTIVE)

    val sleepEvent = UsageEvents.Event() 
    
    val sleepUsageEvents = usageStatsManager.queryEvents( 
        sleepTrackingStartTime,
        sleepTrackingEndTime
    ) 
    
    while (sleepUsageEvents.hasNextEvent()) {
        sleepUsageEvents.getNextEvent(sleepEvent) // 다음 이벤트를 변수에 저장
        if(sleepEvent.eventType == UsageEvents.Event.SCREEN_NON_INTERACTIVE) {
            lastUsageTimeBeforeSleep = sleepEvent.timeStamp 
        } else if (sleepEvent.eventType == UsageEvents.Event.ACTIVITY_STOPPED && sleepEvent.packageName != "com.sec.android.app.launcher") { 
            lastUsedApp = sleepEvent.packageName
        }
    }
    
    Log.e("NIGHT", "마지막 사용 앱: $lastUsedApp, 화면 끈 시각: ${Date(lastUsageTimeBeforeSleep)}")

     

    이제 어제 오후 8시부터 오늘 새벽 4시 사이에 일어난 앱 사용 통계 정보를 받아올 수 있습니다. 여기서, 발생한 정보 단위를 '이벤트' 라고 부릅니다. 오후 8시부터 발생한 이벤트를 이제 하나씩 꺼내오는데, UsageEvents.Event 객체에 이벤트를 담습니다. getNextEvent() 메소드로 이벤트를 꺼낼 때마다 이 객체에 계속해서 덮어씌웁니다. sleepEvent 객체가 가진 이벤트 정보가 만약 SCREEN_NON_INTERACTIVE 라면 사용자가 잠들었을 가능성이 있는 시점으로 보게 됩니다.

    이때, SCREEN_NON_INTERACTIVE 화면이 꺼진 시점에 대한 로그를 나타냅니다. 이후 lastUsageTimeBeforeSleep 변수에다가 해당 시점을 timeStamp 로 기록 및 업데이트합니다. while 문을 통해 남아있는 이벤트가 없을 때까지 확인하기 때문에, 해당 변수에는 가장 마지막으로 핸드폰을 사용하고 화면을 끈 시점이 남아있을 것입니다. 

     

    참고로, SCREEN_NON_INTERACTIVE 말고도 ACTIVITY_STOPPED 라는 이벤트를 통해 언제 앱을 껐는지 로그를 알아낼 수 있습니다. 이벤트 유형이 ACTIVITY_STOPPED 이고, 해당 앱이 기본 화면인 런처 앱(com.sec.android.app.launcher)이 아니라는 조건하에서 마지막으로 사용한 앱이 무엇인지도 알 수 있습니다.

    2. 사용자가 일어난 시각 추적하기

    제가 진행하고 있는 프로젝트 같은 경우에는 사용자가 일어난 시각에 대한 기준을 다음과 같은 플로우로 설정했습니다.

    사용자가 일어난 시점 기준

     

    이걸 보여드린 이유는, 사용자가 일어난 시각을 핸드폰을 처음 켠 시각으로 볼 수도 있고, 기상 알람 미션을 끝낸 시각으로 볼 수도 있기 때문입니다. 저희는 여기서 포스팅 주제에 맞게 핸드폰을 처음 켠 시각으로 가정하여 진행하겠습니다.

     

    2-1. 기상 시간대 지정하기

    val wakeUpCalendar = Calendar.getInstance().apply {
        set(Calendar.HOUR_OF_DAY, 5)
        set(Calendar.MINUTE, 0)
        set(Calendar.SECOND, 0)
        set(Calendar.MILLISECOND, 0)
    }
    val wakeTrackingStartTime = wakeUpCalendar.timeInMillis // 05:00
    wakeUpCalendar.add(Calendar.HOUR_OF_DAY, 5)
    val wakeTrackingEndTime = wakeUpCalendar.timeInMillis // 10:00

     

    UsageStats 매니저를 받아오는 과정은 이미 진행했기에 또 다시 하지 않아도 됩니다. 먼저, 사용자가 기상했을 것 같은 시간대를 지정합니다. 저는 오전 5시에서 오전 10시로 잡았고, 구체적인 시간 계산을 위해 Calendar 객체를 이용했습니다. 아까와 마찬가지로, 오전 5시 전에 일어나거나 오전 10시 이후에 일어나는 경우는 정확한 측정이 어렵다는 단점이 있습니다. 

    2-2. 이벤트 받아오기(SCREEN_INTERACTIVE)

    private var firstUsageTimeAfterWake: Long? = null
    private var firstUsedApp: String? = null
    
    val wakeEvent = UsageEvents.Event()
    val wakeUsageEvents = usageStatsManager.queryEvents(
        wakeTrackingStartTime,
        wakeTrackingEndTime
    ) 
    
    while (wakeUsageEvents.hasNextEvent()) {
        wakeUsageEvents.getNextEvent(wakeEvent)
        if (wakeEvent.eventType == UsageEvents.Event.SCREEN_INTERACTIVE) {
            if (firstUsageTimeAfterWake == null) {
                firstUsageTimeAfterWake = wakeEvent.timeStamp  // 기상 후 처음 핸드폰을 킨 시간 추적
                Log.e("MORNING", "SCREEN_INTERACTIVE: ${Date(firstUsageTimeAfterWake ?: 0L)}")
            }
        } else if (wakeEvent.eventType == UsageEvents.Event.ACTIVITY_RESUMED && wakeEvent.packageName != "com.sec.android.app.launcher") {
            if (firstUsedApp == null) {
                firstUsedApp = wakeEvent.packageName // 기상 후 처음 사용한 앱 추적
            }
        }
    }

     

    잠든 시간을 추적할 때는 관련 이벤트 중 마지막 시점을 확인한 것과 달리, 기상 시간은 가장 첫 번째 이벤트를 파악하는 것이 중요합니다. 먼저, queryEvents 메소드를 통해 금일 오전 5시부터 오전 10시 사이의 이벤트를 wakeUsageEvents 변수에 저장합니다. 이후 저장된 이벤트를 하나씩 꺼내오면서 wakeEvent에 담습니다. 해당 변수가 가진 이벤트가 사용자가 핸드폰 화면을 킬 때 발생하는 SCREEN_INTERACTIVE 이벤트이고, 이전에 저장된 변수 값이 없다면 해당 시간대(timeStamp)를 기상 시간으로 판단합니다. 이후에 SCREEN_INTERACTIVE 이벤트가 발생해도 이미 firstUsageTimeAfterWake 변수 값이 null 이 아니기 때문에 값이 덮어씌워지지 않습니다

     

    이벤트가 ACTIVITY_RESUMED 이고, 핸드폰 기본 화면인 런처 앱이 아니라면 처음 들어간 앱추적할 수 있습니다. 이제 사용자의 취침 시각과 기상 시각을 아주 정확하진 않지만 시스템에 저장된 로그 기록을 통해 얼추(?) 구할 수 있게 되었습니다. 

    3. 시연 영상

    4. 추가 생각/정리

     

    데이터를 얻어오는 방법은 여러 가지가 있습니다. 사용자의 액션, 이벤트로부터 데이터를 얻어올 수도 있고, 서버에 저장된 데이터를 가져올 수도 있습니다. 이 외에도 데이터를 가져올 수 있는 또 다른 방식이 있습니다. 오늘 알아본 '핸드폰 내부 시스템 데이터' 입니다. 내부 시스템에서는 용도에 따라 쓸만한 데이터가 정말 많습니다. 사용자에게 권한을 무조건 얻어야 한다는 단점이 있지만, 권한을 얻게 되면 앱의 용도에 맞게 다양한 데이터를 가져와서 쓸 수 있습니다.

     

    또한, 사용자의 수면 시간을 아주 정확하게 구하려면 사용자가 핸드폰과 인터랙션이 끝난 이후에도 측정을 해야하는데요. 사용자 인근의 소리를 앱에서 백그라운드 서비스로 녹음까지는 할 수 있지만, 그 소리를 통해 잠이 들었는지를 판단하기 위해선 별도의 API, AI 등이 필요할 것 같다는 생각이 듭니다. 이에 대한 아이디어나 생각을 댓글로 남겨주시면 추후 관련 기능을 개선하는데 많은 도움이 될 것 같습니다😆

     

    오늘은 사용자의 수면 시각을 측정하는 기능을 시스템 OS 데이터를 기반으로 알아보았습니다! 9월도 화이팅하세요! 🫠

     

Designed by Tistory.